Skip to content

Commit 3adb0c9

Browse files
authored
Throw on truncated VLQs in strict decode mode (#9)
TokenIterator.nextUnsignedVLQ() kept reading digits as long as the continuation bit was set, without checking whether any input was left. If a scopes string was truncated (or otherwise malformed) so that its last VLQ ended with the continuation bit set, the next read ran past the end of the string: charCodeAt returned NaN, the base64 lookup gave undefined, and `undefined & mask` quietly collapsed to 0. Decoding then stopped and returned a corrupt-but-silent result even in strict mode, which is documented to throw on malformed input. Give TokenIterator a strict flag and bail out when a digit is undefined (EOF or a character outside the base64 alphabet) mid-VLQ: throw in strict mode, keep the existing best-effort behaviour in lax mode. The decoder passes the flag through based on its DecodeMode. Adds unit tests for the truncated and out-of-alphabet cases, plus decode-level tests for a scopes string that ends mid-VLQ in both modes.
1 parent 740da28 commit 3adb0c9

4 files changed

Lines changed: 58 additions & 6 deletions

File tree

src/decode/decode.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,27 @@ describe("decode", () => {
144144
assertStrictEquals(info.scopes[0].kind, "");
145145
});
146146

147+
it("throws in strict mode when the scopes string ends mid-VLQ", () => {
148+
const encoder = new ItemEncoder();
149+
encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem();
150+
// Replace the last digit with 'g', whose continuation bit is set. The
151+
// string now ends while a VLQ still expects another digit.
152+
const truncated = encoder.encode().slice(0, -1) + "g";
153+
const map = createMap(truncated, []);
154+
155+
assertThrows(() => decode(map, { mode: DecodeMode.STRICT }));
156+
});
157+
158+
it("tolerates a scopes string that ends mid-VLQ in lax mode", () => {
159+
const encoder = new ItemEncoder();
160+
encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_START, 0, 0, 0).finishItem();
161+
const truncated = encoder.encode().slice(0, -1) + "g";
162+
const map = createMap(truncated, []);
163+
164+
// Lax mode must not throw; it decodes best-effort.
165+
decode(map, { mode: DecodeMode.LAX });
166+
});
167+
147168
it("throws when encountering an ORIGINAL_SCOPE_END without start in strict mode", () => {
148169
const encoder = new ItemEncoder();
149170
encoder.addUnsignedVLQs(Tag.ORIGINAL_SCOPE_END, 0, 0).finishItem();

src/decode/decode.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,10 @@ class Decoder {
151151
}
152152

153153
decode(): DecodedScopeInfo {
154-
const iter = new TokenIterator(this.#encodedScopes);
154+
const iter = new TokenIterator(
155+
this.#encodedScopes,
156+
this.#mode === DecodeMode.STRICT,
157+
);
155158

156159
while (iter.hasNext()) {
157160
const tag = iter.nextUnsignedVLQ();

src/vlq.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import { describe, it } from "@std/testing/bdd";
66
import { TokenIterator } from "./vlq.ts";
7-
import { assertFalse, assertStrictEquals } from "@std/assert";
7+
import { assertFalse, assertStrictEquals, assertThrows } from "@std/assert";
88

99
describe("TokenIterator", () => {
1010
describe("nextUnsignedVLQ", () => {
@@ -35,6 +35,20 @@ describe("TokenIterator", () => {
3535
assertStrictEquals(iter.nextUnsignedVLQ(), 1);
3636
assertStrictEquals(iter.peek(), "C");
3737
});
38+
39+
it("throws in strict mode when the VLQ is truncated", () => {
40+
// 'h' has the continuation bit set, but the string ends right after it.
41+
const iter = new TokenIterator("h", /* strict */ true);
42+
43+
assertThrows(() => iter.nextUnsignedVLQ());
44+
});
45+
46+
it("throws in strict mode when a continuation runs into an invalid digit", () => {
47+
// 'h' has the continuation bit set, 'æ' is outside the base64 alphabet.
48+
const iter = new TokenIterator("hæC", /* strict */ true);
49+
50+
assertThrows(() => iter.nextUnsignedVLQ());
51+
});
3852
});
3953

4054
describe("nextSignedVLQ", () => {

src/vlq.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,12 @@ export function encodeUnsigned(n: number): string {
4747

4848
export class TokenIterator {
4949
readonly #string: string;
50+
readonly #strict: boolean;
5051
#position: number;
5152

52-
constructor(string: string) {
53+
constructor(string: string, strict = false) {
5354
this.#string = string;
55+
this.#strict = strict;
5456
this.#position = 0;
5557
}
5658

@@ -83,13 +85,25 @@ export class TokenIterator {
8385
nextUnsignedVLQ(): number {
8486
let result = 0;
8587
let shift = 0;
86-
let digit = 0;
88+
let continuation = 0;
8789
do {
8890
const charCode = this.nextCharCode();
89-
digit = BASE64_CODES[charCode];
91+
const digit = BASE64_CODES[charCode];
92+
if (digit === undefined) {
93+
// `charCode` is either `NaN` (we ran past the end of a truncated
94+
// string) or points outside the base64 alphabet. Either way the
95+
// previous digit's continuation bit promised another digit that isn't
96+
// there, so the VLQ is truncated/malformed. In lax mode we stop and
97+
// keep whatever we decoded so far; in strict mode this is an error.
98+
if (this.#strict) {
99+
throw new Error("Encountered truncated or malformed VLQ");
100+
}
101+
break;
102+
}
90103
result += (digit & VLQ_BASE_MASK) << shift;
91104
shift += VLQ_BASE_SHIFT;
92-
} while (digit & VLQ_CONTINUATION_MASK);
105+
continuation = digit & VLQ_CONTINUATION_MASK;
106+
} while (continuation);
93107
return result;
94108
}
95109

0 commit comments

Comments
 (0)