ajv-validator / ajv-validator/ajv

JTD compileParser rejects valid JSON encodings of enum values (raw byte compare against the JSON.stringify form)

Open
#2,651 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
14.8k
Forks
1k
PR merge metrics
No merged PRs in 30d

Description

>
> (`.github/ISSUE_TEMPLATE/bug-or-error-report.md`, label `bug report`). Correctness /
>
> `enum escape`, `parser unicode`. No matching reports found (re-checked 2026-07-20;
> #2595 is a different JTD parser bug, whitespace in empty arrays).

---

**Title:** JTD `compileParser` rejects valid JSON encodings of enum values (raw byte compare against the `JSON.stringify` form)

**What version of Ajv are you using? Does the issue happen if you use the latest version?**

8.20.0, reproduced on HEAD `f177fe32`. `parseEnum` is unchanged on `master`.

**Ajv options object**

```javascript
// defaults; JTD entrypoint
const Ajv = require("ajv/dist/jtd").default
const ajv = new Ajv()
```

**JSON Schema** (JTD)

```json
{"enum": ["Active"]}
```

**Sample data**

A valid JSON encoding of the allowed value `"Active"`, spelling the `A` as a `\u` escape
(the input is the 13-character string below, backslash included):

```
"\u0041ctive"
```

**Your code**

```javascript
const parse = ajv.compileParser(schema)
const input = '"\\u0041ctive"'
console.log("ajv :", parse(input), parse.message)
console.log("oracle:", JSON.parse(input)) // any conformant JSON parser
```

**Validation result, data AFTER validation, error messages**

```
ajv : undefined (parse.message = 'unexpected token \', position 1)
oracle: "Active"
```

`"\u0041ctive"` is a valid JSON encoding of `"Active"`, an allowed enum value, yet the JTD
parser rejects it. The same holds for the escaped solidus and for the parser's own serializer
output:

```
enum ["Active"], input "\u0041ctive" -> ajv: undefined ('unexpected token \') JSON.parse: "Active"
enum ["A/B"], input "A\/B" -> ajv: undefined ('unexpected token A') JSON.parse: "A/B"
enum ["Active"], input "Active" -> ajv: "Active" (agree)
```

The bug breaks ajv's own serialize/parse round-trip. `compileSerializer` escapes characters
through `quote()` (`lib/runtime/quote.ts`), which `\u`-escapes a superset of what
`JSON.stringify` escapes (DEL, C1 controls, `U+00AD`, `U+200C-200F`, `U+2028-202F`,
`U+2060-206F`, `U+FEFF`, ...). `compileParser` then rejects that output. Testing one enum value
per escaped character: 16 of 16 fail to round-trip, e.g.

```
enum ["xy"] (ZWJ U+200D):
serializer output "x\u200dy" (valid JSON, JSON.parse recovers the value)
parser(serializer(v)) = undefined ('unexpected token x')
```

Only the enum path is affected. Every other JTD string path decodes escapes, verified with the
same `\u0041` escape:

```
{type: "string"} -> "Active"
{optionalProperties: {Active: ...}} -> {"Active": true} (escaped key matched)
{values: {type: "string"}} -> {"k": "Active"}
{enum: ["Active"]} -> undefined (only this path rejects)
```

**What results did you expect?**

RFC 8927 defines enum membership on the decoded string value, so a JTD parser should accept
any valid JSON encoding of an allowed value. `parse('"\\u0041ctive"')` for `{enum: ["Active"]}`
should return `"Active"`, matching `JSON.parse`, and the parser should accept everything the
serializer emits.

**Root cause**

`lib/compile/jtd/parse.ts`, `parseEnum` (line 302). Instead of decoding the JSON string token,
the generated code compares raw input bytes against the one encoding `JSON.stringify` happens
to produce:

```javascript
function parseEnum(cxt: ParseCxt): void {
const {gen, data, schema} = cxt
const enumSch = schema.enum
parseToken(cxt, '"')
// TODO loopEnum
gen.if(false)
for (const value of enumSch) {
const valueStr = JSON.stringify(value).slice(1) // remove starting quote
gen.elseIf(_`${jsonSlice(valueStr.length)} === ${valueStr}`)
gen.assign(data, str`${value}`)
gen.add(N.jsonPos, valueStr.length)
}
gen.else()
jsonSyntaxError(cxt)
gen.endIf()
}
```

`JSON.stringify` emits minimal escaping. A conformant producer may emit any equivalent encoding
(`A`, `\/`, escaped control or format characters), and the byte-slice `===` treats those as
non-matches. The other string paths go through `parseJsonString`, which decodes escapes;
`parseEnum` skips it. The `// TODO loopEnum` comment already marks this code as provisional.

**Suggested fix**

Decode the token first, then compare decoded values: route enum through the same
`parseJsonString` machinery the other string paths use, then check membership of the resulting
JS string in `schema.enum`. That makes the comparison encoding-independent and restores the
serialize/parse round-trip. A regression test should cover `\uXXXX`, `\/`, and round-tripping
serializer output for the characters `quote()` escapes.

**Are you going to resolve the issue?**

I can help. The clean fix reuses the `parseJsonString` path for the token; flagging the design
choice (decode-then-compare vs the current byte-slice) for maintainer preference first.

---
*Found via property-based & differential bug-hunting, part of an effort to scale PBT (DepTyCheck-based) testing across the OSS ecosystem.*

*If this is intended / by-design: I'm really sorry, please just close it — no need to flag or ban me. I'm trying to scale property-based testing across the whole ecosystem and my publishing agents may have gotten this one wrong. I read every issue and follow up on each.*

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.