Skip to content

Commit 9f66986

Browse files
authored
fix: Fix bug stringifying number-like keys and values (#645)
A string like "1e 6" will currently be stringified as 1e+6, which is ambiguous because it looks like a number literal and it will be parsed as one. This patch fixes the stringify code so that these strings are quoted. AQF strings have a similar issue and this patch addresses them as well.
1 parent d502347 commit 9f66986

File tree

4 files changed

+240
-40
lines changed

4 files changed

+240
-40
lines changed

src/JsonURL.js

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { setupToJsonURLText, toJsonURLText } from "./proto.js";
3333

3434
const RX_DECODE_SPACE = /\+/g;
3535
const RX_ENCODE_SPACE = / /g;
36-
const RX_AQF_DECODE = /(![\s\S]?)/g;
36+
const RX_AQF_DECODE_ESCAPE = /(![\s\S]?)/g;
3737

3838
//
3939
// patterns for use with RegEx.test().
@@ -42,7 +42,9 @@ const RX_AQF_DECODE = /(![\s\S]?)/g;
4242
const RX_ENCODE_STRING_SAFE =
4343
/^[-A-Za-z0-9._~!$*;@?/ ][-A-Za-z0-9._~!$*;@?/' ]*$/;
4444
const RX_ENCODE_STRING_QSAFE = /^[-A-Za-z0-9._~!$*,;@?/(): ]+$/;
45-
const RX_ENCODE_NUMBER = /^-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?$/;
45+
const RX_ENCODE_NUMBER = /^-?\d+(?:\.\d+)?(?:[eE][-]?\d+)?$/;
46+
const RX_ENCODE_NUMBER_PLUS = /^-?\d+(?:\.\d+)?[eE]\+\d+$/;
47+
const RX_ENCODE_NUMBER_SPACE = /^-?\d+(?:\.\d+)?[eE] \d+$/;
4648

4749
const RX_ENCODE_BASE = /[(),:]|%2[04]|%3B/gi;
4850
const RX_ENCODE_BASE_MAP = {
@@ -55,7 +57,7 @@ const RX_ENCODE_BASE_MAP = {
5557
"%3B": ";",
5658
};
5759

58-
const RX_ENCODE_AQF = /[!(),:]|%2[01489C]|%3[AB]/gi;
60+
const RX_ENCODE_AQF = /[!(),:]|%2[01489BC]|%3[AB]/gi;
5961
const RX_ENCODE_AQF_MAP = {
6062
"%20": "+",
6163
"%21": "!!",
@@ -66,6 +68,7 @@ const RX_ENCODE_AQF_MAP = {
6668
"%29": "!)",
6769
")": "!)",
6870
"+": "!+",
71+
"%2B": "!+",
6972
"%2C": "!,",
7073
",": "!,",
7174
"%3A": "!:",
@@ -131,6 +134,7 @@ UNESCAPE[CHAR_n] = "n";
131134
const EMPTY_STRING = "";
132135
const EMPTY_STRING_AQF = "!e";
133136
const SPACE = " ";
137+
const PLUS = "+";
134138

135139
function newEmptyString(pos, emptyOK) {
136140
if (emptyOK) {
@@ -205,7 +209,17 @@ function hexDecode(pos, c) {
205209
}
206210
}
207211

208-
function isBoolNullNumber(s) {
212+
function isBang(s, offset) {
213+
return (
214+
s.charCodeAt(offset - 1) === CHAR_BANG ||
215+
(offset > 2 &&
216+
s.charCodeAt(offset - 3) === CHAR_PERCENT &&
217+
s.charCodeAt(offset - 2) === CHAR_0 + 2 &&
218+
s.charCodeAt(offset - 1) === CHAR_0 + 1)
219+
);
220+
}
221+
222+
function isBoolNullNoPlusNumber(s) {
209223
if (s === "true" || s === "false" || s === "null") {
210224
return true;
211225
}
@@ -269,30 +283,41 @@ function toJsonURLText_String(options, depth, isKey) {
269283
return encodeStringLiteral(this, options.AQF);
270284
}
271285

272-
if (isBoolNullNumber(this)) {
286+
if (isBoolNullNoPlusNumber(this)) {
273287
//
274-
// if this string looks like a Boolean, Number, or ``null'' literal
275-
// then it must be quoted
288+
// this string looks like a boolean, `null`, or number literal without
289+
// a plus char
276290
//
277291
if (isKey === true) {
292+
// keys are assumed to be strings
278293
return this;
279294
}
280295
if (options.AQF) {
281-
if (this.indexOf("+") == -1) {
282-
return "!" + this;
283-
}
284-
return this.replace("+", "!+");
285-
}
286-
if (this.indexOf("+") == -1) {
287-
return "'" + this + "'";
296+
return "!" + this;
288297
}
298+
return "'" + this + "'";
299+
}
289300

301+
if (RX_ENCODE_NUMBER_PLUS.test(this)) {
302+
//
303+
// this string looks like a number with an exponent that includes a `+`
304+
//
305+
if (options.AQF) {
306+
return this.replace(PLUS, "!+");
307+
}
308+
return this.replace(PLUS, "%2B");
309+
}
310+
if (RX_ENCODE_NUMBER_SPACE.test(this)) {
290311
//
291-
// if the string needs to be encoded then it no longer looks like a
292-
// literal and does not need to be quoted.
312+
// this string would look like a number if it were allowed to have a
313+
// space represented as a plus
293314
//
294-
return encodeURIComponent(this);
315+
if (options.AQF) {
316+
return "!" + this.replace(SPACE, "+");
317+
}
318+
return "'" + this.replace(SPACE, "+") + "'";
295319
}
320+
296321
if (options.AQF) {
297322
return encodeStringLiteral(this, true);
298323
}
@@ -957,10 +982,6 @@ class ParserAQF extends Parser {
957982
return ret;
958983
}
959984

960-
acceptPlus() {
961-
return this.accept(CHAR_PLUS);
962-
}
963-
964985
findLiteralEnd() {
965986
const end = this.end;
966987
const pos = this.pos;
@@ -1025,7 +1046,16 @@ class ParserAQF extends Parser {
10251046
const text = this.text;
10261047
const pos = this.pos;
10271048
const ret = decodeURIComponent(
1028-
text.substring(pos, litend).replace(RX_DECODE_SPACE, SPACE)
1049+
text
1050+
.substring(pos, litend)
1051+
.replace(RX_DECODE_SPACE, function (match, offset) {
1052+
if (offset === 0 || !isBang(text, pos + offset)) {
1053+
return SPACE;
1054+
}
1055+
return PLUS;
1056+
// const c = text.charCodeAt(pos + offset - 1);
1057+
// return c === CHAR_BANG ? PLUS : SPACE;
1058+
})
10291059
);
10301060

10311061
this.pos = litend;
@@ -1034,7 +1064,7 @@ class ParserAQF extends Parser {
10341064
return EMPTY_STRING;
10351065
}
10361066

1037-
return ret.replace(RX_AQF_DECODE, function name(match, _p, offset) {
1067+
return ret.replace(RX_AQF_DECODE_ESCAPE, function (match, _p, offset) {
10381068
if (match.length === 2) {
10391069
const c = match.charCodeAt(1);
10401070
const uc = UNESCAPE[c];

test/parse.test.js

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ test.each([
9696
["('Hello,+(World)!')", ["Hello, (World)!"]],
9797
["('','')", ["", ""]],
9898
["('qkey':g)", { qkey: "g" }],
99-
["1e%2B1", "1e+1"],
10099
])("JsonURL.parse(%s)", (text, expected) => {
101100
expect(u.parse(text)).toEqual(expected);
102101
expect(JsonURL.parse(text)).toEqual(expected);
@@ -116,7 +115,6 @@ test.each([
116115
["('Hello!,+!(World!)!!')", ["'Hello, (World)!'"]],
117116
["(!e,!e)", ["", ""]],
118117
["(!e:g)", { "": "g" }],
119-
["1e%2B1", 10],
120118
["%48%45%4C%4C%4F%21,+%57%4F%52%4C%44!!", "HELLO, WORLD!"],
121119
["%28%61%3A%62%2C%63%3A%64%29", { a: "b", c: "d" }],
122120
["%28%61%2C%62%2C%63%2c%64%29", ["a", "b", "c", "d"]],
@@ -150,6 +148,96 @@ test.each([
150148
expect(parseAQF(text, options)).toEqual(expected);
151149
});
152150

151+
//
152+
// Test edge cases for number-like strings combined with various options
153+
//
154+
test.each([
155+
[
156+
"1e+1",
157+
"1e%2B1",
158+
"1e!+1",
159+
"1e%2B1",
160+
"1e!+1",
161+
"1e%2B1",
162+
"1e%2B1",
163+
"1e!+1",
164+
"1e!+1",
165+
],
166+
[
167+
"1e+3",
168+
"1e+3",
169+
"1e+3",
170+
"1e%2B3",
171+
"1e!+3",
172+
"1e%2B3",
173+
"1e%2B3",
174+
"1e!+3",
175+
"1e!+3",
176+
],
177+
[
178+
"1e 3",
179+
"'1e+3'",
180+
"!1e+3",
181+
"1e+3",
182+
"1e+3",
183+
"'1e+3'",
184+
"1e+3",
185+
"!1e+3",
186+
"1e+3",
187+
],
188+
])(
189+
"JsonURL.parseNumberLikeString(%p)",
190+
(
191+
expected,
192+
inputKey,
193+
inputKeyAqf,
194+
inputKeyIsl,
195+
inputKeyIslAqf,
196+
inputBase,
197+
inputImpliedStringLiteral,
198+
inputAqf,
199+
inputImpliedStringAqf
200+
) => {
201+
function makeObject(s) {
202+
const ret = {};
203+
ret[s] = "a";
204+
return ret;
205+
}
206+
function makeText(s) {
207+
return "(" + s + ":a)";
208+
}
209+
210+
expect(JsonURL.parse(makeText(inputKey))).toEqual(makeObject(expected));
211+
expect(JsonURL.parse(makeText(inputKeyAqf), { AQF: true })).toEqual(
212+
makeObject(expected)
213+
);
214+
expect(
215+
JsonURL.parse(makeText(inputKeyIsl), { impliedStringLiterals: true })
216+
).toEqual(makeObject(expected));
217+
218+
expect(
219+
JsonURL.parse(makeText(inputKeyIslAqf), {
220+
AQF: true,
221+
impliedStringLiterals: true,
222+
})
223+
).toEqual(makeObject(expected));
224+
225+
expect(u.parse(inputBase)).toBe(expected);
226+
expect(
227+
u.parse(inputImpliedStringLiteral, { impliedStringLiterals: true })
228+
).toBe(expected);
229+
expect(u.parse(inputAqf, { AQF: true })).toBe(expected);
230+
expect(
231+
u.parse(inputImpliedStringAqf, { AQF: true, impliedStringLiterals: true })
232+
).toBe(expected);
233+
}
234+
);
235+
236+
/*
237+
(text, value, aqfValue, impliedStrValue) => {
238+
239+
*/
240+
153241
test.each([undefined])("JsonURL.parse(%p)", (text) => {
154242
expect(u.parse(text)).toBeUndefined();
155243
expect(JsonURL.parse(text)).toBeUndefined();

test/parseLiteral.test.js

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,12 @@ function escapeStringAQF(s) {
4545
.replace(/:/, "!:");
4646
}
4747

48-
function runTest(text, value, keyValue, strLitValue) {
48+
function runTest(text, value, keyValue, impliedStrValue) {
4949
expect(u.parseLiteral(text)).toBe(value);
50-
expect(JsonURL.parse(text)).toBe(value);
5150
expect(u.parseLiteral(text, 0, text.length, true)).toBe(keyValue);
5251
expect(
5352
u.parseLiteral(text, 0, text.length, true, { impliedStringLiterals: true })
54-
).toBe(strLitValue);
53+
).toBe(impliedStrValue);
5554

5655
//
5756
// verify that parseLiteral() and parse() return the same thing (as
@@ -61,10 +60,10 @@ function runTest(text, value, keyValue, strLitValue) {
6160
expect(JsonURL.parse(text)).toBe(value);
6261
}
6362

64-
function runTestAQF(text, value, strLitValue) {
63+
function runTestAQF(text, value, impliedStrValue) {
6564
expect(u.parse(text, { AQF: true })).toBe(value);
6665
expect(u.parse(text, { AQF: true, impliedStringLiterals: true })).toBe(
67-
strLitValue
66+
impliedStrValue
6867
);
6968
}
7069

@@ -147,14 +146,15 @@ test.each([
147146
runTestAQF(textAQF, value, keyValue);
148147
});
149148

150-
// eslint-disable-next-line jest/expect-expect
151149
test.each([
152150
//
153151
// fixed point
154152
//
155153
["-3e0", -3, undefined, "-3e0"],
156154
["1e+2", 1e2, undefined, "1e 2"],
157155
["-2e+1", -2e1, undefined, "-2e 1"],
156+
["1e-2", 1e-2, undefined, "1e-2"],
157+
["1e+2", 1e2, undefined, "1e 2"],
158158

159159
//
160160
// floating point
@@ -164,26 +164,33 @@ test.each([
164164
//
165165
// string
166166
//
167-
["'hello'", "hello", "'hello'", undefined],
167+
["'hello'", "hello", "'hello'", "'hello'"],
168168
["hello%2Bworld", "hello+world", undefined, undefined],
169169
["y+%3D+mx+%2B+b", "y = mx + b", undefined, undefined],
170170
["a%3Db%26c%3Dd", "a=b&c=d", undefined, undefined],
171171
["hello%F0%9F%8D%95world", "hello\uD83C\uDF55world", undefined, undefined],
172172
["-e+", "-e ", undefined, undefined],
173173
["-e+1", "-e 1", undefined, undefined],
174-
["1e%2B1", "1e+1", 10, "1e+1"],
174+
["1e%2B1", "1e+1", "1e+1", "1e+1"],
175175
["%26true", "&true", undefined, undefined],
176176
["%3Dtrue", "=true", undefined, undefined],
177-
])("JsonURL.parseLiteral(%p)", (text, value, aqfValue, strLitValue) => {
177+
])("JsonURL.parseLiteral(%p)", (text, value, aqfValue, impliedStrValue) => {
178178
let keyValue = typeof value === "string" ? value : text;
179179
if (aqfValue === undefined) {
180180
aqfValue = value;
181181
}
182-
if (strLitValue === undefined) {
183-
strLitValue = aqfValue;
184-
}
185-
runTest(text, value, keyValue, strLitValue);
186-
runTestAQF(text, aqfValue, strLitValue);
182+
183+
runTest(
184+
text,
185+
value,
186+
keyValue,
187+
impliedStrValue === undefined ? keyValue : impliedStrValue
188+
);
189+
190+
expect(u.parse(text, { AQF: true })).toBe(aqfValue);
191+
expect(u.parse(text, { AQF: true, impliedStringLiterals: true })).toBe(
192+
impliedStrValue === undefined ? aqfValue : impliedStrValue
193+
);
187194
});
188195

189196
test("JsonURL.parseLiteral('null')", () => {

0 commit comments

Comments
 (0)