Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 109 additions & 0 deletions src/__tests__/boolean-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Tests for boolean-like dictionary keys.
*
* Verifies the fix for a bug where dictionary keys like "f", "t",
* "true", "false", "null" were incorrectly parsed as boolean/null values
* instead of being preserved as strings.
*/

import { encode, decode } from '../index';

describe('Boolean-like dictionary keys', () => {
test('Key "f" should not become false', () => {
const data = { f: 1 };
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded).toEqual({ f: 1 });
expect(Object.keys(decoded)).toContain('f');
expect(Object.keys(decoded)).not.toContain('false');
});

test('Key "t" should not become true', () => {
const data = { t: 1 };
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded).toEqual({ t: 1 });
expect(Object.keys(decoded)).toContain('t');
expect(Object.keys(decoded)).not.toContain('true');
});

test('Key "true" should not become boolean true', () => {
const data = { true: 1 };
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded).toEqual({ true: 1 });
expect(Object.keys(decoded)).toContain('true');
});

test('Key "false" should not become boolean false', () => {
const data = { false: 1 };
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded).toEqual({ false: 1 });
expect(Object.keys(decoded)).toContain('false');
});

test('Key "null" should not become null', () => {
const data = { null: 1 };
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded).toEqual({ null: 1 });
expect(Object.keys(decoded)).toContain('null');
});

test('Key "none" should not become null', () => {
const data = { none: 1 };
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded).toEqual({ none: 1 });
expect(Object.keys(decoded)).toContain('none');
});

test('Key "nil" should not become null', () => {
const data = { nil: 1 };
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded).toEqual({ nil: 1 });
expect(Object.keys(decoded)).toContain('nil');
});

test('Case variants of boolean-like keys are preserved', () => {
const cases = [
{ F: 1 },
{ T: 1 },
{ True: 1 },
{ False: 1 },
{ TRUE: 1 },
{ FALSE: 1 },
{ NULL: 1 },
{ NONE: 1 },
{ Null: 1 },
];
for (const data of cases) {
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded).toEqual(data);
}
});

test('Multiple boolean-like keys in same object', () => {
const data = { t: 1, f: 2, true: 3, false: 4, null: 5 };
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded.t).toBe(1);
expect(decoded.f).toBe(2);
expect(decoded.true).toBe(3);
expect(decoded.false).toBe(4);
expect(decoded.null).toBe(5);
});

test('Nested object with boolean-like key round-trips', () => {
const data = { a: { b: { c: { d: { e: { f: 1 } } } } } };
const encoded = encode(data);
const decoded = decode(encoded) as any;
expect(decoded).toEqual(data);
const inner = decoded?.a?.b?.c?.d?.e;
expect(Object.keys(inner)).toContain('f');
expect(Object.keys(inner)).not.toContain('false');
});
});
59 changes: 59 additions & 0 deletions src/__tests__/dict-header-quoting.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Tests for dictionary header value quoting.
*
* Verifies the fix for a bug where dictionary compression header values
* containing commas or colons were truncated at the delimiter, causing
* incorrect round-trips.
*/

import { encode, decode } from '../index';

describe('Dictionary header value quoting', () => {
test('Dictionary values with commas round-trip correctly', () => {
const data = [
{ Car: 'Toyota Celica ST 185', Year: '1996' },
{ Car: 'Toyota Corolla, AWD', Year: '1997' },
{ Car: 'Toyota Corolla, AWD', Year: '1998' },
{ Car: 'Toyota Corolla, AWD', Year: '1999' },
{ Car: 'Toyota Corolla, AWD', Year: '2000' },
{ Car: 'Toyota Corolla, AWD', Year: '2001' },
];
const encoded = encode(data);
const decoded = decode(encoded) as any[];
expect(decoded[0].Car).toBe('Toyota Celica ST 185');
expect(decoded[1].Car).toBe('Toyota Corolla, AWD');
expect(decoded[1].Year).toBe('1997');
});

test('Dictionary values with colons round-trip correctly', () => {
const data = [
{ key: 'value:one', other: 'a' },
{ key: 'value:one', other: 'b' },
{ key: 'value:one', other: 'c' },
{ key: 'value:one', other: 'd' },
{ key: 'value:one', other: 'e' },
{ key: 'value:two', other: 'f' },
];
const encoded = encode(data);
const decoded = decode(encoded) as any[];
expect(decoded[0].key).toBe('value:one');
expect(decoded[5].key).toBe('value:two');
});

test('All rows preserved after dictionary decompression', () => {
const data = [
{ Car: 'Toyota Celica ST 185', Year: '1996' },
{ Car: 'Toyota Corolla, AWD', Year: '1997' },
{ Car: 'Toyota Corolla, AWD', Year: '1998' },
{ Car: 'Toyota Corolla, AWD', Year: '1999' },
{ Car: 'Toyota Corolla, AWD', Year: '2000' },
{ Car: 'Toyota Corolla, AWD', Year: '2001' },
];
const encoded = encode(data);
const decoded = decode(encoded) as any[];
expect(decoded).toHaveLength(data.length);
for (let i = 0; i < data.length; i++) {
expect(decoded[i]).toEqual(data[i]);
}
});
});
16 changes: 10 additions & 6 deletions src/core/decoder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { TABLE_MARKER, META_SEPARATOR, MAX_DOCUMENT_SIZE, MAX_LINE_LENGTH, MAX_ARRAY_LENGTH, MAX_OBJECT_KEYS, MAX_NESTING_DEPTH } from './constants';
import { ZonDecodeError } from './exceptions';
import { parseValue } from './utils';
import { parseValue, parseKey } from './utils';
import { extractVersion, stripVersion, type ZonDocumentMetadata } from './versioning';

export interface DecodeOptions {
Expand Down Expand Up @@ -94,7 +94,7 @@ export class ZonDecoder {
const dictMatch = trimmedLine.match(/^([\w\.]+)\[(\d+)\]:(.+)$/);
if (dictMatch && !trimmedLine.startsWith(TABLE_MARKER)) {
const [, col, , vals] = dictMatch;
pendingDictionaries.set(col, vals.split(','));
pendingDictionaries.set(col, this._splitByDelimiter(vals, ',').map(v => this._parseValue(v)));
continue;
}

Expand Down Expand Up @@ -612,7 +612,7 @@ export class ZonDecoder {
continue;
}

const key = this._parseValue(keyStr);
const key = this._parseKey(keyStr);
const val = this._parseZonNode(valStr, depth + 1);
obj[key] = val;
}
Expand Down Expand Up @@ -745,7 +745,7 @@ export class ZonDecoder {
// Handle implicit array item starting with dash in value?
// No, _parseZonNode handles that recursively.

const key = this._parseValue(keyStr);
const key = this._parseKey(keyStr);
const val = this._parseZonNode(valStr, depth + 1);
obj[key] = val;
}
Expand Down Expand Up @@ -823,7 +823,7 @@ export class ZonDecoder {
const colonIdx = this._findDelimiter(item, ':');
const keyStr = item.substring(0, colonIdx).trim();
const valStr = item.substring(colonIdx + 1).trim();
const key = this._parseValue(keyStr);
const key = this._parseKey(keyStr);
const val = this._parseZonNode(valStr, depth + 1);
obj[key] = val;
}
Expand Down Expand Up @@ -856,7 +856,7 @@ export class ZonDecoder {
const colonIdx = this._findDelimiter(line.trim(), ':');
const keyStr = line.substring(0, colonIdx).trim();
const valStr = line.substring(colonIdx + 1).trim();
const key = this._parseValue(keyStr);
const key = this._parseKey(keyStr);
const val = this._parseZonNode(valStr, depth + 1);
obj[key] = val;
}
Expand Down Expand Up @@ -1043,6 +1043,10 @@ export class ZonDecoder {
return parsed;
}

private _parseKey(val: string): string {
return parseKey(val);
}



/**
Expand Down
7 changes: 5 additions & 2 deletions src/core/encoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,9 @@ export class ZonEncoder {
const lines: string[] = [];

for (const [col, values] of dictionaries) {
lines.push(`${col}[${values.length}]:${values.join(',')}`);
// Quote dictionary values that contain special characters
const formattedValues = values.map(v => this._formatValue(v));
lines.push(`${col}[${values.length}]:${formattedValues.join(',')}`);
}

const dictCols = Array.from(dictionaries.keys());
Expand Down Expand Up @@ -628,7 +630,8 @@ export class ZonEncoder {
const items: string[] = [];
for (const k of keys) {
let kStr = String(k);
if (/[,:\{\}\[\]"]/.test(kStr)) {
// Quote keys with special chars OR boolean/null keywords
if (/[,:\{\}\[\]"]/.test(kStr) || /^(true|false|t|f|null|none|nil)$/i.test(kStr)) {
kStr = JSON.stringify(kStr);
}

Expand Down
35 changes: 35 additions & 0 deletions src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,41 @@ export function quoteString(s: string): string {
return `"${zon}"`;
}

/**
* Parses a ZON dictionary key string.
*
* Unlike parseValue, this does NOT convert boolean keywords (t, f, true, false)
* or null keywords to their typed equivalents. Keys are always strings.
*
* @param val - The key string to parse
* @returns The parsed key as a string
*/
export function parseKey(val: string): string {
const trimmed = val.trim();

if (trimmed.startsWith('"')) {
try {
return JSON.parse(trimmed);
} catch {
if (trimmed.endsWith('"')) {
const inner = trimmed.slice(1, -1);
const fixed = inner.replace(/""/g, '\\"');
try {
return JSON.parse(`"${fixed}"`);
} catch {
return trimmed.slice(1, -1).replace(/""/g, '"');
}
}
}
}

if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
return trimmed.slice(1, -1);
}

return trimmed;
}

/**
* Parses a ZON value string into its corresponding primitive type.
* Handles booleans, nulls, numbers, arrays, objects, and quoted strings.
Expand Down
Loading