diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a4faf0..2f274c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project are documented in this file. ## Unreleased +- Replace native and callback serialization error messages with a value-free + `TypeError` so a cyclic key or callback exception cannot copy private input + into diagnostics. Strict unsupported-value errors retain positional detail. +- Reject unknown option names and report unsupported nested object properties + by source position, including legal empty-name properties. +- Separate `undefined` from unsupported types when a top-level value cannot be + represented as JSON, so the error names the category instead of using one + shared message. +- Add the optional `onUnsupported` option. `'omit'` is the default and keeps + existing successful-serialization behavior; `'throw'` reports an + unrepresentable nested value and its source position rather than silently + dropping it. +- Document the supported-value contract in the README. +- Add an HTML raw-text parse fixture that extracts the embedded value back out + of a rendered page, covering Unicode round-trips and confirming a hostile + payload cannot terminate its containing script element. + ## 0.1.1 - 2026-08-24 - Expand the threat model, compatibility guidance, context matrix, examples, diff --git a/README.md b/README.md index 7252999..34d7f28 100644 --- a/README.md +++ b/README.md @@ -82,9 +82,56 @@ serializeInlineJson(value, { }) ``` -Options follow native `JSON.stringify` behavior with one deliberate -restriction: `space` must be an integer from 0 through 10. A top-level value -that cannot be represented as JSON throws instead of returning `undefined`. +Options are a plain object with only `replacer`, `space`, and +`onUnsupported` as own enumerable data properties; unknown or inherited +options are rejected rather than silently changing the omission policy. +The supported replacer and spacing behavior follows native `JSON.stringify`, +except `space` must be an integer from 0 through 10. A top-level value +that cannot be represented as JSON throws instead of returning `undefined`, and +the error distinguishes an `undefined` value from an unsupported type. + +### Supported values + +| Input | Result | +| --- | --- | +| Object, array, string, finite number, boolean, `null` | Serialized | +| `Date`, or any value with `toJSON` | Serialized via `toJSON` | +| Non-finite number (`NaN`, `±Infinity`) | `null` | +| `undefined`, function, symbol — top level | Throws, naming the category | +| `undefined`, function, symbol — nested | See `onUnsupported` below | +| `BigInt` | Throws a value-free `TypeError` | +| Cyclic structure | Throws a value-free `TypeError` | + +### `onUnsupported` + +JSON cannot represent `undefined`, functions or symbols. Native +`JSON.stringify` drops such a property from an object and turns such an element +into `null` in an array — silently, so embedded page data can end up missing +fields the author expected to be there. + +- `'omit'` (default) keeps that native omission behavior for successful + serialization. +- `'throw'` reports the value and its source position instead: + +```js +serializeInlineJson({ user: { id: 1, email: undefined } }, { + onUnsupported: 'throw', +}) +// TypeError: Cannot represent the undefined value at property[0].property[1] as JSON; +// remove it or serialize with onUnsupported: 'omit'. +``` + +Object positions are zero-based in `JSON.stringify` visitation order; array +positions keep their numeric indices (for example, `property[0][2]`). Raw +property names are never copied into diagnostics, because they may contain +control characters or private data. Distinct object and array locations remain +distinguishable. +Values are inspected after `toJSON` and after a function replacer, so a value +that *becomes* unrepresentable is reported too. + +`onUnsupported: 'throw'` requires a function replacer or no replacer. An array +replacer is a property allowlist that omits properties by design, so combining +the two is rejected rather than given a confusing meaning. ## What it escapes @@ -117,11 +164,16 @@ The detailed security assumptions and abuse cases are in ## Native JSON behavior retained - Cyclic values and `BigInt` values throw. -- Unsupported object properties are omitted. +- Unsupported object properties are omitted by default; set + `onUnsupported: 'throw'` to be told about them instead. - Non-finite numbers become `null`. - Getters, `toJSON`, and replacer callbacks execute normally. - A replacer or `toJSON` implementation can have side effects; this package does not isolate user code. +- Errors thrown during serialization are replaced with a value-free `TypeError` + unless they are this package's own positional strict-mode diagnostic. Native + cycle errors can otherwise echo private property names, and callback errors + can carry arbitrary input. The original error and cause are not attached. ## Architecture diff --git a/docs/architecture.md b/docs/architecture.md index 1175158..b3460cc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,8 +21,11 @@ and the compatibility contract without improving the supported use case. - Invalid options fail before serialization. - Unsupported top-level values throw instead of returning `undefined`. -- Native failures for cycles and `BigInt` are preserved. -- Replacer, getter, or `toJSON` exceptions propagate unchanged. +- Cycles and `BigInt` still fail with `TypeError`, but their native messages are + replaced with a value-free diagnostic because native cycle errors can name + private properties. +- Replacer, getter, or `toJSON` exceptions are also replaced with a value-free + `TypeError`; callbacks still execute and can have side effects. ## Release boundary diff --git a/docs/threat-model.md b/docs/threat-model.md index 191fdfb..ff9a11a 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -52,6 +52,13 @@ with a JSON Unicode escape. Because the serialized output contains no literal The escapes remain valid JSON and reconstruct the original characters when parsed. +Serialization failures are also a report surface. Native cycle errors may +include raw property names, and getters, `toJSON`, or replacer callbacks may +throw messages containing arbitrary data. Except for this package's own +source-position-only strict diagnostic, such errors are replaced with a +value-free `TypeError` without the original message or cause. This does not +stop callbacks from executing or undo their side effects. + ## Trust assumptions - The surrounding HTML element and its attributes are created safely. diff --git a/index.d.ts b/index.d.ts index 1421e1e..00bb827 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,9 +2,21 @@ export type InlineJsonReplacer = | ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[] +/** + * How to treat values JSON cannot represent (`undefined`, functions, symbols) + * below the top level. + * + * - `'omit'` (default) keeps native `JSON.stringify` behaviour: the property is + * omitted from an object and becomes `null` in an array. + * - `'throw'` reports the value and its path instead of dropping it. Requires a + * function replacer or no replacer. + */ +export type InlineJsonUnsupportedHandling = 'omit' | 'throw' + export interface SerializeInlineJsonOptions { replacer?: InlineJsonReplacer space?: number + onUnsupported?: InlineJsonUnsupportedHandling } export declare function serializeInlineJson( diff --git a/index.mjs b/index.mjs index 60e1abf..57e9ed9 100644 --- a/index.mjs +++ b/index.mjs @@ -6,14 +6,38 @@ const JSON_ESCAPES = Object.freeze({ '\u2029': '\\u2029', }) +const UNSUPPORTED_HANDLING = Object.freeze(['omit', 'throw']) +const OPTION_KEYS = Object.freeze(['replacer', 'space', 'onUnsupported']) + function assertOptions(options) { if (options === undefined) return {} if (options === null || typeof options !== 'object' || Array.isArray(options)) { throw new TypeError('Options must be an object when provided.') } - - return options + let keys + let descriptors + try { + if (![Object.prototype, null].includes(Object.getPrototypeOf(options))) { + throw new TypeError('Options must be a plain object.') + } + keys = Reflect.ownKeys(options) + descriptors = Object.getOwnPropertyDescriptors(options) + } catch { + throw new TypeError('Options must be a plain object with data properties.') + } + const selected = {} + for (const key of keys) { + if (typeof key !== 'string' || !OPTION_KEYS.includes(key)) { + throw new TypeError('Unknown option.') + } + const descriptor = descriptors[key] + if (!Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) { + throw new TypeError('Options must use enumerable data properties.') + } + selected[key] = descriptor.value + } + return selected } function assertSpace(space) { @@ -25,21 +49,150 @@ function assertSpace(space) { } } +function assertUnsupported(onUnsupported) { + if ( + onUnsupported !== undefined && + !UNSUPPORTED_HANDLING.includes(onUnsupported) + ) { + throw new TypeError("Option onUnsupported must be 'omit' or 'throw' when provided.") + } +} + +/** + * Classify a value that JSON cannot represent. + * + * `undefined` is a representable absence; a function or symbol is an + * unsupported type. JSON.stringify treats both the same way — omitted in an + * object, `null` in an array — so the distinction is made here. + * + * @returns {'undefined' | 'function' | 'symbol' | null} null when representable + */ +function categorize(value) { + if (value === undefined) return 'undefined' + + const type = typeof value + if (type === 'function' || type === 'symbol') return type + + return null +} + +function childPath(holderPath, holder, key, ordinal) { + if (Array.isArray(holder)) return `${holderPath}[${Number(key)}]` + const position = `property[${ordinal}]` + return holderPath === '' ? position : `${holderPath}.${position}` +} + +/** + * Wrap a replacer so that values JSON cannot represent are reported with their + * location instead of being dropped. Only used when `onUnsupported` is + * `'throw'`; otherwise the caller's replacer is passed through untouched so + * native behaviour is preserved exactly. + */ +function strictReplacer(replacer, noteOwnError) { + const paths = new WeakMap() + const nextOrdinals = new WeakMap() + let rootSeen = false + + return function trackingReplacer(key, entry) { + const next = replacer === undefined ? entry : replacer.call(this, key, entry) + + // JSON.stringify also permits an ordinary property named ''. Only its + // first callback is the wrapper root, regardless of later property keys. + if (!rootSeen) { + rootSeen = true + if (next !== null && typeof next === 'object') { + paths.set(next, '') + nextOrdinals.set(next, 0) + } + return next + } + + // Every holder reached here was registered before JSON.stringify recursed + // into it: the root is registered on the key === '' call above, and each + // nested object is registered below before it becomes a holder in turn. + const ordinal = nextOrdinals.get(this) + nextOrdinals.set(this, ordinal + 1) + const path = childPath(paths.get(this), this, key, ordinal) + const category = categorize(next) + + if (category !== null) { + const error = new TypeError( + `Cannot represent the ${category} value at ${path} as JSON; ` + + "remove it or serialize with onUnsupported: 'omit'.", + ) + noteOwnError(error) + throw error + } + + if (next !== null && typeof next === 'object') { + paths.set(next, path) + nextOrdinals.set(next, 0) + } + + return next + } +} + +function topLevelMessage(value, hasFunctionReplacer) { + if (hasFunctionReplacer) { + return 'The top-level value cannot be represented as JSON.' + } + + const category = value === undefined ? 'undefined' : typeof value + + return category === 'undefined' + ? 'The top-level value is undefined and cannot be represented as JSON.' + : `The top-level value is an unsupported ${category} and cannot be represented as JSON.` +} + /** * Serialize a value as JSON that can be placed in the raw-text content of an * HTML script element. * * @param {unknown} value - * @param {{ replacer?: ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[], space?: number }} [options] + * @param {{ replacer?: ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[], space?: number, onUnsupported?: 'omit' | 'throw' }} [options] * @returns {string} */ export function serializeInlineJson(value, options) { - const { replacer, space } = assertOptions(options) + const { replacer, space, onUnsupported } = assertOptions(options) assertSpace(space) - const serialized = JSON.stringify(value, replacer, space) + assertUnsupported(onUnsupported) + + const strict = onUnsupported === 'throw' + const arrayReplacer = Array.isArray(replacer) + + if (strict && arrayReplacer) { + throw new TypeError( + "Option onUnsupported: 'throw' requires a function replacer or no replacer, " + + 'because a property allowlist omits properties by design.', + ) + } + + let ownError = null + let hasOwnError = false + const effectiveReplacer = strict + ? strictReplacer(replacer, error => { + ownError = error + hasOwnError = true + }) + : replacer + let serialized + try { + serialized = JSON.stringify(value, effectiveReplacer, space) + } catch (error) { + // Native cycle diagnostics can contain raw object keys; callbacks may + // throw messages containing arbitrary input too. Preserve only our own + // positional strict diagnostic, never the original message or cause. + // Match the exact error created by this call: callbacks can throw a + // fabricated TypeError or even reuse a mutated error from an earlier call. + if (hasOwnError && error === ownError) throw error + throw new TypeError('JSON serialization failed; inspect the input and callbacks locally.') + } if (serialized === undefined) { - throw new TypeError('The top-level value cannot be represented as JSON.') + throw new TypeError( + topLevelMessage(value, !arrayReplacer && typeof replacer === 'function'), + ) } return serialized.replace(/[<>&\u2028\u2029]/g, (character) => JSON_ESCAPES[character]) diff --git a/test/html-embedding.test.mjs b/test/html-embedding.test.mjs new file mode 100644 index 0000000..eaa7a71 --- /dev/null +++ b/test/html-embedding.test.mjs @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { serializeInlineJson } from '../index.mjs' + +/** + * Minimal raw-text extractor used as a local parser fixture. + * + * A script element's content is raw text: it ends at the first ``, case-insensitively. That rule is the + * whole reason this package escapes `<`, so the fixture implements exactly it + * rather than depending on a full HTML parser. It is a test fixture, not a + * general-purpose HTML parser. + */ +function readScriptData(html, id) { + const opening = new RegExp(`]*\\bid="${id}"[^>]*>`, 'i').exec(html) + assert.ok(opening, `no script element with id "${id}"`) + + const body = html.slice(opening.index + opening[0].length) + const end = /<\/script[\s/>]/i.exec(body) + assert.ok(end, 'script element was never closed') + + return { data: body.slice(0, end.index), remainder: body.slice(end.index) } +} + +function renderPage(value, options) { + return ` + + t + + +

after

+ +` +} + +test('round-trips nested Unicode data through an HTML parse', () => { + const value = { + title: 'Edilec — outils', + emoji: '🛠️', + scripts: ['日本語', 'Ελληνικά', 'العربية', 'עברית'], + nested: { deep: { combining: 'é', astral: '𝔘𝔫𝔦' } }, + separators: 'one\u2028two\u2029three', + } + + const { data } = readScriptData(renderPage(value, { space: 2 }), 'page-data') + + assert.deepEqual(JSON.parse(data), value) +}) + +test('a hostile payload cannot terminate its containing script element', () => { + const value = { + a: '', + b: '', + c: '-->', + '': 'in a key too', + } + + const html = renderPage(value) + const { data, remainder } = readScriptData(html, 'page-data') + + // The element ends exactly once, at the real closing tag we emitted. + assert.ok(remainder.startsWith('')) + assert.equal(html.match(/<\/script[\s/>]/gi).length, 1) + + // And the payload survives intact. + assert.deepEqual(JSON.parse(data), value) +}) + +test('the document structure after the script element is unaffected', () => { + const { remainder } = readScriptData( + renderPage({ escape: '

injected

' }), + 'page-data', + ) + + assert.equal(remainder.match(/id="after"/g).length, 1) +}) diff --git a/test/index.test.mjs b/test/index.test.mjs index 76e0fbf..8221bc0 100644 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -109,10 +109,73 @@ test('rejects an unsupported top-level value', () => { assert.throws(() => serializeInlineJson(() => {}), /top-level value/) }) -test('retains native JSON.stringify failures for BigInt and cyclic data', () => { +test('rejects BigInt and cyclic data with TypeError', () => { assert.throws(() => serializeInlineJson(1n), TypeError) const cyclic = {} cyclic.self = cyclic assert.throws(() => serializeInlineJson(cyclic), TypeError) }) + +test('serialization failures do not echo cyclic property names in either mode', () => { + const canary = 'SYNTHETIC_SECRET_CANARY' + const hidden = String.fromCodePoint(0x202e) + const cyclic = {} + cyclic[`token=${canary}${hidden}`] = cyclic + + for (const options of [undefined, { onUnsupported: 'throw' }]) { + assert.throws(() => serializeInlineJson(cyclic, options), (error) => { + assert.ok(error instanceof TypeError) + assert.ok(error.message.length > 0) + assert.equal(String(error.stack).includes(canary), false) + assert.equal(String(error.stack).includes(hidden), false) + return true + }) + } +}) + +test('getter and replacer exceptions do not echo their private messages', () => { + const canary = 'SYNTHETIC_SECRET_CANARY' + const withGetter = { get value() { throw new Error(canary) } } + const throwingReplacer = () => { throw new Error(canary) } + + for (const attempt of [ + () => serializeInlineJson(withGetter), + () => serializeInlineJson(withGetter, { onUnsupported: 'throw' }), + () => serializeInlineJson({ value: 1 }, { replacer: throwingReplacer }), + () => serializeInlineJson({ value: 1 }, { replacer: throwingReplacer, onUnsupported: 'throw' }), + () => serializeInlineJson({ value: 1 }, { replacer: () => { throw null }, onUnsupported: 'throw' }), + ]) { + assert.throws(attempt, (error) => { + assert.ok(error instanceof TypeError) + assert.ok(error.message.length > 0) + assert.equal(String(error.stack).includes(canary), false) + return true + }) + } +}) + +test('a callback cannot forge or reuse a prior strict error to expose private text', () => { + let prior + assert.throws(() => serializeInlineJson({ missing: undefined }, { onUnsupported: 'throw' }), (error) => { + prior = error + return true + }) + const canary = 'SYNTHETIC_SECRET_CANARY' + const fabricated = new prior.constructor(canary) + prior.message = canary + + for (const thrown of [fabricated, prior]) { + for (const options of [ + { replacer: () => { throw thrown } }, + { replacer: () => { throw thrown }, onUnsupported: 'throw' }, + ]) { + assert.throws(() => serializeInlineJson({ value: 1 }, options), (error) => { + assert.ok(error instanceof TypeError) + assert.ok(error.message.length > 0) + assert.equal(String(error.stack).includes(canary), false) + return true + }) + } + } +}) diff --git a/test/unsupported-values.test.mjs b/test/unsupported-values.test.mjs new file mode 100644 index 0000000..b497bec --- /dev/null +++ b/test/unsupported-values.test.mjs @@ -0,0 +1,190 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { serializeInlineJson } from '../index.mjs' + +test('distinguishes an undefined top-level value from an unsupported type', () => { + assert.throws( + () => serializeInlineJson(undefined), + /top-level value is undefined/, + ) + assert.throws( + () => serializeInlineJson(Symbol('value')), + /top-level value is an unsupported symbol/, + ) + assert.throws( + () => serializeInlineJson(() => {}), + /top-level value is an unsupported function/, + ) +}) + +test('keeps the generic top-level message when a function replacer produced the gap', () => { + assert.throws( + () => serializeInlineJson({ a: 1 }, { replacer: () => undefined }), + /^TypeError: The top-level value cannot be represented as JSON\.$/, + ) +}) + +test('omits unrepresentable values by default, preserving native behaviour', () => { + const serialized = serializeInlineJson({ + kept: 1, + dropped: undefined, + fn() {}, + sym: Symbol('s'), + list: [1, undefined, 3], + }) + + assert.deepEqual(JSON.parse(serialized), { kept: 1, list: [1, null, 3] }) +}) + +test('omit is accepted explicitly and behaves like the default', () => { + const value = { kept: 1, dropped: undefined } + + assert.equal( + serializeInlineJson(value, { onUnsupported: 'omit' }), + serializeInlineJson(value), + ) +}) + +test('strict mode reports a dropped object property by source position', () => { + assert.throws( + () => serializeInlineJson({ a: { b: undefined } }, { onUnsupported: 'throw' }), + /Cannot represent the undefined value at property\[0\]\.property\[0\] as JSON/, + ) +}) + +test('strict mode reports an unsupported array element with its index', () => { + assert.throws( + () => serializeInlineJson({ list: [1, Symbol('s')] }, { onUnsupported: 'throw' }), + /Cannot represent the symbol value at property\[0\]\[1\] as JSON/, + ) + assert.throws( + () => serializeInlineJson([() => {}], { onUnsupported: 'throw' }), + /Cannot represent the function value at \[0\] as JSON/, + ) +}) + +test('strict mode reports a top-level property position without a leading separator', () => { + assert.throws( + () => serializeInlineJson({ missing: undefined }, { onUnsupported: 'throw' }), + /value at property\[0\] as JSON/, + ) +}) + +test('strict mode inspects legal empty-name properties at root and nested levels', () => { + assert.equal(serializeInlineJson({ '': 1 }, { onUnsupported: 'throw' }), '{"":1}') + assert.throws( + () => serializeInlineJson({ '': undefined }, { onUnsupported: 'throw' }), + /Cannot represent the undefined value/, + ) + assert.throws( + () => serializeInlineJson({ outer: { '': undefined } }, { onUnsupported: 'throw' }), + /Cannot represent the undefined value/, + ) +}) + +test('strict mode inspects values after toJSON and after a function replacer', () => { + assert.throws( + () => + serializeInlineJson( + { wrapped: { toJSON: () => undefined } }, + { onUnsupported: 'throw' }, + ), + /undefined value at property\[0\] as JSON/, + ) + + assert.throws( + () => + serializeInlineJson( + { a: 1 }, + { + onUnsupported: 'throw', + replacer(key, entry) { + return key === 'a' ? undefined : entry + }, + }, + ), + /undefined value at property\[0\] as JSON/, + ) +}) + +test('strict mode passes representable data through unchanged and still escapes', () => { + const value = { nested: { list: [1, ''], ok: null } } + const strict = serializeInlineJson(value, { onUnsupported: 'throw' }) + + assert.equal(strict, serializeInlineJson(value)) + assert.doesNotMatch(strict, /[<>&\u2028\u2029]/u) + assert.deepEqual(JSON.parse(strict), value) +}) + +test('strict mode accepts a non-object top-level value', () => { + assert.equal(serializeInlineJson('plain', { onUnsupported: 'throw' }), '"plain"') + assert.equal(serializeInlineJson(7, { onUnsupported: 'throw' }), '7') +}) + +test('strict mode rejects an array replacer, which omits properties by design', () => { + assert.throws( + () => serializeInlineJson({ a: 1 }, { replacer: ['a'], onUnsupported: 'throw' }), + /requires a function replacer or no replacer/, + ) +}) + +test('rejects an unknown onUnsupported value', () => { + assert.throws( + () => serializeInlineJson({}, { onUnsupported: 'ignore' }), + /must be 'omit' or 'throw'/, + ) + assert.throws( + () => serializeInlineJson({}, { onUnsupported: null }), + /must be 'omit' or 'throw'/, + ) +}) + +test('unknown option names cannot silently turn strict omission into a pass', () => { + const value = { known: undefined } + assert.throws(() => serializeInlineJson(value, { onUnsupported: 'throw' }), TypeError) + assert.throws( + () => serializeInlineJson(value, { onUnsupproted: 'throw' }), + /Unknown option/, + ) + const canary = 'token=SYNTHETIC_SECRET_CANARY' + assert.throws( + () => serializeInlineJson(value, { [canary]: 'throw' }), + error => error instanceof TypeError && !error.message.includes(canary), + ) + assert.throws( + () => serializeInlineJson(value, { [Symbol('hidden')]: true }), + /Unknown option/, + ) + const inherited = Object.create({ onUnsupported: 'throw' }) + assert.throws(() => serializeInlineJson(value, inherited), /plain object/) + let getterCalled = false + const accessor = { get onUnsupported() { getterCalled = true; return 'throw' } } + assert.throws(() => serializeInlineJson(value, accessor), /data properties/) + assert.equal(getterCalled, false) + const plain = Object.assign(Object.create(null), { onUnsupported: 'throw' }) + assert.throws(() => serializeInlineJson(value, plain), TypeError) +}) + +test('strict diagnostics locate properties without echoing or conflating raw keys', () => { + const messageFor = value => { + try { + serializeInlineJson(value, { onUnsupported: 'throw' }) + assert.fail('strict serialization should refuse an unsupported value') + } catch (error) { + assert.ok(error instanceof TypeError) + return error.message + } + } + for (const key of ['a\nb', `a${String.fromCodePoint(0x202e)}b`, + 'token=SYNTHETIC_SECRET_CANARY']) { + const message = messageFor({ [key]: undefined }) + assert.equal(message.includes(key), false) + assert.equal(message.includes('\n'), false) + assert.equal(message.includes(String.fromCodePoint(0x202e)), false) + assert.equal(message.includes('SYNTHETIC_SECRET_CANARY'), false) + assert.match(message, /property\[0\]/) + } + assert.notEqual(messageFor({ 'a.b': undefined }), messageFor({ a: { b: undefined } })) + assert.notEqual(messageFor({ 'items[0]': undefined }), messageFor({ items: [undefined] })) +})