diff --git a/javascript/packages/fory/lib/fory.ts b/javascript/packages/fory/lib/fory.ts index d26b6b6fa7..2d039b169d 100644 --- a/javascript/packages/fory/lib/fory.ts +++ b/javascript/packages/fory/lib/fory.ts @@ -30,6 +30,9 @@ import { PlatformBuffer } from "./platformBuffer"; import { TypeMetaResolver } from "./typeMetaResolver"; import { MetaStringResolver } from "./metaStringResolver"; +const DEFAULT_DEPTH_LIMIT = 50 as const; +const MIN_DEPTH_LIMIT = 2 as const; + export default class { binaryReader: BinaryReader; binaryWriter: BinaryWriter; @@ -40,9 +43,16 @@ export default class { anySerializer: Serializer; typeMeta = TypeMeta; config: Config; + depth = 0; + maxDepth: number; constructor(config?: Partial) { this.config = this.initConfig(config); + const maxDepth = config?.maxDepth ?? DEFAULT_DEPTH_LIMIT; + if (!Number.isInteger(maxDepth) || maxDepth < MIN_DEPTH_LIMIT) { + throw new Error(`maxDepth must be an integer >= ${MIN_DEPTH_LIMIT} but got ${maxDepth}`); + } + this.maxDepth = maxDepth; this.binaryReader = new BinaryReader(this.config); this.binaryWriter = new BinaryWriter(this.config); this.referenceResolver = new ReferenceResolver(this.binaryReader); @@ -57,6 +67,7 @@ export default class { return { refTracking: config?.refTracking !== null ? Boolean(config?.refTracking) : null, useSliceString: Boolean(config?.useSliceString), + maxDepth: config?.maxDepth, hooks: config?.hooks || {}, compatible: Boolean(config?.compatible), }; @@ -66,6 +77,34 @@ export default class { return this.config.compatible === true; } + incReadDepth(): void { + this.depth++; + if (this.depth > this.maxDepth) { + throw new Error( + `Deserialization depth limit exceeded: ${this.depth} > ${this.maxDepth}. ` + + "The data may be malicious, or increase maxDepth if needed." + ); + } + } + + decReadDepth(): void { + this.depth--; + } + + private resetRead(): void { + this.referenceResolver.resetRead(); + this.typeMetaResolver.resetRead(); + this.metaStringResolver.resetRead(); + this.depth = 0; + } + + private resetWrite(): void { + this.binaryWriter.reset(); + this.referenceResolver.resetWrite(); + this.metaStringResolver.resetWrite(); + this.typeMetaResolver.resetWrite(); + } + registerSerializer(constructor: new () => T, customSerializer: CustomSerializer): { serializer: Serializer; serialize(data: InputType | null): PlatformBuffer; @@ -141,10 +180,8 @@ export default class { } deserialize(bytes: Uint8Array, serializer: Serializer = this.anySerializer): T | null { - this.referenceResolver.reset(); + this.resetRead(); this.binaryReader.reset(bytes); - this.typeMetaResolver.reset(); - this.metaStringResolver.reset(); const bitmap = this.binaryReader.readUint8(); if ((bitmap & ConfigFlags.isNullFlag) === ConfigFlags.isNullFlag) { return null; @@ -162,16 +199,13 @@ export default class { private serializeInternal(data: T, serializer: Serializer) { try { - this.binaryWriter.reset(); + this.resetWrite(); } catch (e) { if (e instanceof OwnershipError) { throw new Error("Permission denied. To release the serialization ownership, you must call the dispose function returned by serializeVolatile."); } throw e; } - this.referenceResolver.reset(); - this.metaStringResolver.reset(); - this.typeMetaResolver.reset(); let bitmap = 0; if (data === null) { bitmap |= ConfigFlags.isNullFlag; diff --git a/javascript/packages/fory/lib/gen/collection.ts b/javascript/packages/fory/lib/gen/collection.ts index 9278b6dcc0..396885abba 100644 --- a/javascript/packages/fory/lib/gen/collection.ts +++ b/javascript/packages/fory/lib/gen/collection.ts @@ -45,6 +45,13 @@ class CollectionAnySerializer { } + private readSerializerWithDepth(serializer: Serializer, fromRef: boolean) { + this.fory.incReadDepth(); + const result = serializer.read(fromRef); + this.fory.decReadDepth(); + return result; + } + protected writeElementsHeader(arr: any) { let flag = 0; let isSame = true; @@ -161,7 +168,7 @@ class CollectionAnySerializer { const refId = this.fory.binaryReader.readVarUInt32(); accessor(result, i, this.fory.referenceResolver.getReadObject(refId)); } else if (refFlag === RefFlags.RefValueFlag) { - accessor(result, i, serializer!.read(true)); + accessor(result, i, this.readSerializerWithDepth(serializer!, true)); } else { accessor(result, i, null); } @@ -172,12 +179,12 @@ class CollectionAnySerializer { if (flag === RefFlags.NullFlag) { accessor(result, i, null); } else { - accessor(result, i, serializer!.read(false)); + accessor(result, i, this.readSerializerWithDepth(serializer!, false)); } } } else { for (let i = 0; i < len; i++) { - accessor(result, i, serializer!.read(false)); + accessor(result, i, this.readSerializerWithDepth(serializer!, false)); } } } else { @@ -193,13 +200,13 @@ class CollectionAnySerializer { accessor(result, i, null); } else { const itemSerializer = AnyHelper.detectSerializer(this.fory); - accessor(result, i, itemSerializer!.read(false)); + accessor(result, i, this.readSerializerWithDepth(itemSerializer!, false)); } } } else { for (let i = 0; i < len; i++) { const itemSerializer = AnyHelper.detectSerializer(this.fory); - accessor(result, i, itemSerializer!.read(false)); + accessor(result, i, this.readSerializerWithDepth(itemSerializer!, false)); } } } @@ -306,7 +313,7 @@ export abstract class CollectionSerializerGenerator extends BaseSerializerGenera switch (${refFlag}) { case ${RefFlags.NotNullValueFlag}: case ${RefFlags.RefValueFlag}: - ${this.innerGenerator.readEmbed().read((x: any) => `${this.putAccessor(result, x, idx)}`, `${refFlag} === ${RefFlags.RefValueFlag}`)} + ${this.innerGenerator.readWithDepth((x: any) => `${this.putAccessor(result, x, idx)}`, `${refFlag} === ${RefFlags.RefValueFlag}`)} break; case ${RefFlags.RefFlag}: ${this.putAccessor(result, this.builder.referenceResolver.getReadObject(this.builder.reader.readVarUInt32()), idx)} @@ -321,13 +328,13 @@ export abstract class CollectionSerializerGenerator extends BaseSerializerGenera if (${this.builder.reader.readInt8()} == ${RefFlags.NullFlag}) { ${this.putAccessor(result, "null", idx)} } else { - ${this.innerGenerator.readEmbed().read((x: any) => `${this.putAccessor(result, x, idx)}`, "false")} + ${this.innerGenerator.readWithDepth((x: any) => `${this.putAccessor(result, x, idx)}`, "false")} } } } else { for (let ${idx} = 0; ${idx} < ${len}; ${idx}++) { - ${this.innerGenerator.readEmbed().read((x: any) => `${this.putAccessor(result, x, idx)}`, "false")} + ${this.innerGenerator.readWithDepth((x: any) => `${this.putAccessor(result, x, idx)}`, "false")} } } ${accessor(result)} diff --git a/javascript/packages/fory/lib/gen/ext.ts b/javascript/packages/fory/lib/gen/ext.ts index c9c18133e1..a708f15ec9 100644 --- a/javascript/packages/fory/lib/gen/ext.ts +++ b/javascript/packages/fory/lib/gen/ext.ts @@ -61,9 +61,14 @@ class ExtSerializerGenerator extends BaseSerializerGenerator { } readNoRef(assignStmt: (v: string) => string, refState: string): string { + const result = this.scope.uniqueName("result"); return ` ${this.readTypeInfo()} - ${this.read(assignStmt, refState)}; + fory.incReadDepth(); + let ${result}; + ${this.read(v => `${result} = ${v}`, refState)}; + fory.decReadDepth(); + ${assignStmt(result)}; `; } diff --git a/javascript/packages/fory/lib/gen/map.ts b/javascript/packages/fory/lib/gen/map.ts index d94f05606a..7f80bbc20d 100644 --- a/javascript/packages/fory/lib/gen/map.ts +++ b/javascript/packages/fory/lib/gen/map.ts @@ -145,6 +145,13 @@ class MapAnySerializer { } + private readSerializerWithDepth(serializer: Serializer, fromRef: boolean) { + this.fory.incReadDepth(); + const result = serializer.read(fromRef); + this.fory.decReadDepth(); + return result; + } + private writeFlag(header: number, v: any) { if (header & MapFlags.HAS_NULL) { return true; @@ -215,21 +222,21 @@ class MapAnySerializer { } if (!trackingRef) { serializer = serializer == null ? AnyHelper.detectSerializer(this.fory) : serializer; - return serializer!.read(false); + return this.readSerializerWithDepth(serializer!, false); } const flag = this.fory.binaryReader.readInt8(); switch (flag) { case RefFlags.RefValueFlag: serializer = serializer == null ? AnyHelper.detectSerializer(this.fory) : serializer; - return serializer!.read(true); + return this.readSerializerWithDepth(serializer!, true); case RefFlags.RefFlag: return this.fory.referenceResolver.getReadObject(this.fory.binaryReader.readVarUInt32()); case RefFlags.NullFlag: return null; case RefFlags.NotNullValueFlag: serializer = serializer == null ? AnyHelper.detectSerializer(this.fory) : serializer; - return serializer!.read(false); + return this.readSerializerWithDepth(serializer!, false); } } @@ -427,7 +434,7 @@ export class MapSerializerGenerator extends BaseSerializerGenerator { const flag = ${this.builder.reader.readInt8()}; switch (flag) { case ${RefFlags.RefValueFlag}: - ${this.keyGenerator.read(x => `key = ${x}`, "true")} + ${this.keyGenerator.readWithDepth(x => `key = ${x}`, "true")} break; case ${RefFlags.RefFlag}: key = ${this.builder.referenceResolver.getReadObject(this.builder.reader.readVarUInt32())} @@ -436,11 +443,11 @@ export class MapSerializerGenerator extends BaseSerializerGenerator { key = null; break; case ${RefFlags.NotNullValueFlag}: - ${this.keyGenerator.read(x => `key = ${x}`, "false")} + ${this.keyGenerator.readWithDepth(x => `key = ${x}`, "false")} break; } } else { - ${this.keyGenerator.read(x => `key = ${x}`, "false")} + ${this.keyGenerator.readWithDepth(x => `key = ${x}`, "false")} } if (valueIncludeNone) { @@ -449,7 +456,7 @@ export class MapSerializerGenerator extends BaseSerializerGenerator { const flag = ${this.builder.reader.readInt8()}; switch (flag) { case ${RefFlags.RefValueFlag}: - ${this.valueGenerator.read(x => `value = ${x}`, "true")} + ${this.valueGenerator.readWithDepth(x => `value = ${x}`, "true")} break; case ${RefFlags.RefFlag}: value = ${this.builder.referenceResolver.getReadObject(this.builder.reader.readVarUInt32())} @@ -458,11 +465,11 @@ export class MapSerializerGenerator extends BaseSerializerGenerator { value = null; break; case ${RefFlags.NotNullValueFlag}: - ${this.valueGenerator.read(x => `value = ${x}`, "false")} + ${this.valueGenerator.readWithDepth(x => `value = ${x}`, "false")} break; } } else { - ${this.valueGenerator.read(x => `value = ${x}`, "false")} + ${this.valueGenerator.readWithDepth(x => `value = ${x}`, "false")} } ${result}.set( diff --git a/javascript/packages/fory/lib/gen/serializer.ts b/javascript/packages/fory/lib/gen/serializer.ts index 912ce095b2..2f4c79ff11 100644 --- a/javascript/packages/fory/lib/gen/serializer.ts +++ b/javascript/packages/fory/lib/gen/serializer.ts @@ -47,6 +47,7 @@ export interface SerializerGenerator { readRef(assignStmt: (v: string) => string): string; readRefWithoutTypeInfo(assignStmt: (v: string) => string): string; readNoRef(assignStmt: (v: string) => string, refState: string): string; + readWithDepth(assignStmt: (v: string) => string, refState: string): string; readTypeInfo(): string; read(assignStmt: (v: string) => string, refState: string): string; readEmbed(): any; @@ -186,6 +187,17 @@ export abstract class BaseSerializerGenerator implements SerializerGenerator { abstract read(assignStmt: (v: string) => string, refState: string): string; + readWithDepth(assignStmt: (v: string) => string, refState: string): string { + const result = this.scope.uniqueName("result"); + return ` + fory.incReadDepth(); + let ${result}; + ${this.read(v => `${result} = ${v}`, refState)}; + fory.decReadDepth(); + ${assignStmt(result)}; + `; + } + readTypeInfo(): string { const typeId = this.getTypeId(); const readUserTypeStmt = TypeId.needsUserTypeId(typeId) && typeId !== TypeId.COMPATIBLE_STRUCT @@ -200,26 +212,29 @@ export abstract class BaseSerializerGenerator implements SerializerGenerator { readNoRef(assignStmt: (v: string) => string, refState: string): string { return ` ${this.readTypeInfo()} - ${this.read(assignStmt, refState)}; + ${this.readWithDepth(assignStmt, refState)} `; } readRefWithoutTypeInfo(assignStmt: (v: string) => string): string { const refFlag = this.scope.uniqueName("refFlag"); + const result = this.scope.uniqueName("result"); return ` const ${refFlag} = ${this.builder.reader.readInt8()}; + let ${result}; switch (${refFlag}) { case ${RefFlags.NotNullValueFlag}: case ${RefFlags.RefValueFlag}: - ${this.read(assignStmt, `${refFlag} === ${RefFlags.RefValueFlag}`)} + ${this.readWithDepth(v => `${result} = ${v}`, `${refFlag} === ${RefFlags.RefValueFlag}`)} break; case ${RefFlags.RefFlag}: - ${assignStmt(this.builder.referenceResolver.getReadObject(this.builder.reader.readVarUInt32()))} + ${result} = ${this.builder.referenceResolver.getReadObject(this.builder.reader.readVarUInt32())}; break; case ${RefFlags.NullFlag}: - ${assignStmt("null")} + ${result} = null; break; } + ${assignStmt(result)}; `; } diff --git a/javascript/packages/fory/lib/gen/struct.ts b/javascript/packages/fory/lib/gen/struct.ts index 33ed243197..161724f1de 100644 --- a/javascript/packages/fory/lib/gen/struct.ts +++ b/javascript/packages/fory/lib/gen/struct.ts @@ -83,7 +83,7 @@ class StructSerializerGenerator extends BaseSerializerGenerator { ${embedGenerator.readRefWithoutTypeInfo(assignStmt)} `; } else { - stmt = embedGenerator.read(assignStmt, "false"); + stmt = embedGenerator.readWithDepth(assignStmt, "false"); } } else { if (refMode == RefMode.TRACKING || refMode === RefMode.NULL_ONLY) { @@ -207,13 +207,18 @@ class StructSerializerGenerator extends BaseSerializerGenerator { } readNoRef(assignStmt: (v: string) => string, refState: string): string { + const result = this.scope.uniqueName("result"); return ` ${this.readTypeInfo()} + fory.incReadDepth(); + let ${result}; if (${this.metaChangedSerializer} !== null) { - ${assignStmt(`${this.metaChangedSerializer}.read(${refState})`)} + ${result} = ${this.metaChangedSerializer}.read(${refState}); } else { - ${this.read(assignStmt, refState)}; + ${this.read(v => `${result} = ${v}`, refState)}; } + fory.decReadDepth(); + ${assignStmt(result)}; `; } diff --git a/javascript/packages/fory/lib/metaStringResolver.ts b/javascript/packages/fory/lib/metaStringResolver.ts index 50f4afc098..ccf0ca6d04 100644 --- a/javascript/packages/fory/lib/metaStringResolver.ts +++ b/javascript/packages/fory/lib/metaStringResolver.ts @@ -108,4 +108,15 @@ export class MetaStringResolver { }); this.dynamicNameId = 0; } + + resetRead() { + // No state to reset for read operation + } + + resetWrite() { + this.disposeMetaStringBytes.forEach((x) => { + x.dynamicWriteStringId = -1; + }); + this.dynamicNameId = 0; + } } diff --git a/javascript/packages/fory/lib/referenceResolver.ts b/javascript/packages/fory/lib/referenceResolver.ts index ec919c966a..0602151521 100644 --- a/javascript/packages/fory/lib/referenceResolver.ts +++ b/javascript/packages/fory/lib/referenceResolver.ts @@ -35,6 +35,14 @@ export class ReferenceResolver { this.writeObjects = new Map(); } + resetRead() { + this.readObjects = []; + } + + resetWrite() { + this.writeObjects = new Map(); + } + getReadObject(refId: number) { return this.readObjects[refId]; } diff --git a/javascript/packages/fory/lib/type.ts b/javascript/packages/fory/lib/type.ts index 983c4797ce..52f320869f 100644 --- a/javascript/packages/fory/lib/type.ts +++ b/javascript/packages/fory/lib/type.ts @@ -264,6 +264,7 @@ export interface Config { hps?: Hps; refTracking: boolean | null; useSliceString: boolean; + maxDepth?: number; hooks: { afterCodeGenerated?: (code: string) => string; }; diff --git a/javascript/packages/fory/lib/typeMetaResolver.ts b/javascript/packages/fory/lib/typeMetaResolver.ts index ad13ea22a5..b60531d7a3 100644 --- a/javascript/packages/fory/lib/typeMetaResolver.ts +++ b/javascript/packages/fory/lib/typeMetaResolver.ts @@ -121,4 +121,16 @@ export class TypeMetaResolver { this.dynamicTypeId = 0; this.typeMeta = []; } + + resetRead() { + this.typeMeta = []; + } + + resetWrite() { + this.disposeTypeInfo.forEach((x) => { + x.dynamicTypeId = -1; + }); + this.disposeTypeInfo = []; + this.dynamicTypeId = 0; + } } diff --git a/javascript/test/depthLimit.test.ts b/javascript/test/depthLimit.test.ts new file mode 100644 index 0000000000..384794cc5c --- /dev/null +++ b/javascript/test/depthLimit.test.ts @@ -0,0 +1,400 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Fory, { Type } from '../packages/fory/index'; +import { describe, expect, test } from '@jest/globals'; + +describe('depth-limit', () => { + describe('configuration', () => { + test('should have default maxDepth of 50', () => { + const fory = new Fory(); + expect(fory.maxDepth).toBe(50); + }); + + test('should accept custom maxDepth', () => { + const fory = new Fory({ maxDepth: 100 }); + expect(fory.maxDepth).toBe(100); + }); + + test('should initialize depth counter to 0', () => { + const fory = new Fory(); + expect(fory.depth).toBe(0); + }); + + test('should reject maxDepth < 2', () => { + expect(() => new Fory({ maxDepth: 1 })).toThrow( + 'maxDepth must be an integer >= 2 but got 1' + ); + }); + + test('should reject maxDepth = 0', () => { + expect(() => new Fory({ maxDepth: 0 })).toThrow( + 'maxDepth must be an integer >= 2' + ); + }); + + test('should reject negative maxDepth', () => { + expect(() => new Fory({ maxDepth: -5 })).toThrow( + 'maxDepth must be an integer >= 2' + ); + }); + + test('should reject NaN maxDepth', () => { + expect(() => new Fory({ maxDepth: Number.NaN })).toThrow( + 'maxDepth must be an integer >= 2' + ); + }); + + test('should reject non-integer maxDepth', () => { + expect(() => new Fory({ maxDepth: 2.5 })).toThrow( + 'maxDepth must be an integer >= 2' + ); + }); + }); + + describe('depth operations', () => { + test('should have incReadDepth method', () => { + const fory = new Fory(); + expect(typeof fory.incReadDepth).toBe('function'); + }); + + test('should have decReadDepth method', () => { + const fory = new Fory(); + expect(typeof fory.decReadDepth).toBe('function'); + }); + + test('incReadDepth should increment depth', () => { + const fory = new Fory({ maxDepth: 100 }); + expect(fory.depth).toBe(0); + fory.incReadDepth(); + expect(fory.depth).toBe(1); + fory.incReadDepth(); + expect(fory.depth).toBe(2); + }); + + test('decReadDepth should decrement depth', () => { + const fory = new Fory({ maxDepth: 100 }); + fory.incReadDepth(); + fory.incReadDepth(); + expect(fory.depth).toBe(2); + fory.decReadDepth(); + expect(fory.depth).toBe(1); + fory.decReadDepth(); + expect(fory.depth).toBe(0); + }); + + test('incReadDepth should throw when depth exceeds limit', () => { + const fory = new Fory({ maxDepth: 2 }); + fory.incReadDepth(); // depth = 1 + fory.incReadDepth(); // depth = 2 + expect(() => fory.incReadDepth()).toThrow( + 'Deserialization depth limit exceeded: 3 > 2' + ); + }); + + test('depth error message should mention limit and hint', () => { + const fory = new Fory({ maxDepth: 5 }); + try { + for (let i = 0; i < 6; i++) { + fory.incReadDepth(); + } + throw new Error('Should have thrown depth limit error'); + } catch (e) { + expect(e.message).toContain('Deserialization depth limit exceeded'); + expect(e.message).toContain('5'); + expect(e.message).toContain('increase maxDepth if needed'); + } + }); + }); + + describe('deserialization with depth tracking', () => { + test('should deserialize simple struct without depth error', () => { + const fory = new Fory({ maxDepth: 50 }); + const typeInfo = Type.struct({ + typeName: 'simple.struct', + }, { + a: Type.int32(), + b: Type.string(), + }); + + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + const data = { a: 42, b: 'hello' }; + const serialized = serialize(data); + const deserialized = deserialize(serialized); + + expect(deserialized).toEqual(data); + expect(fory.depth).toBe(0); // Should be reset after deserialization + }); + + test('should deserialize nested struct within depth limit', () => { + const fory = new Fory({ maxDepth: 10 }); + const nestedType = Type.struct({ + typeName: 'nested.outer', + }, { + value: Type.int32(), + inner: Type.struct({ + typeName: 'nested.inner', + }, { + innerValue: Type.int32(), + }).setNullable(true), + }); + + const { serialize, deserialize } = fory.registerSerializer(nestedType); + const data = { value: 1, inner: { innerValue: 2 } }; + const serialized = serialize(data); + const deserialized = deserialize(serialized); + + expect(deserialized).toEqual(data); + expect(fory.depth).toBe(0); // Should be reset after deserialization + }); + + test('should deserialize array of primitives within depth limit', () => { + const fory = new Fory({ maxDepth: 10 }); + const arrayType = Type.array(Type.int32()); + + const { serialize, deserialize } = fory.registerSerializer(arrayType); + const data = [1, 2, 3, 4, 5]; + const serialized = serialize(data); + const deserialized = deserialize(serialized); + + expect(deserialized).toEqual(data); + expect(fory.depth).toBe(0); // Should be 0 after deserialization + }); + + test('should deserialize map within depth limit', () => { + const fory = new Fory({ maxDepth: 10 }); + const mapType = Type.map(Type.string(), Type.int32()); + + const { serialize, deserialize } = fory.registerSerializer(mapType); + const data = new Map([['a', 1], ['b', 2]]); + const serialized = serialize(data); + const deserialized = deserialize(serialized); + + expect(deserialized).toEqual(data); + expect(fory.depth).toBe(0); // Should be 0 after deserialization + }); + + test('should throw when nested arrays exceed maxDepth', () => { + const fory = new Fory({ maxDepth: 2 }); + const nestedArrayType = Type.array(Type.array(Type.array(Type.int32()))); + const { serialize, deserialize } = fory.registerSerializer(nestedArrayType); + const serialized = serialize([[[1]]]); + + expect(() => deserialize(serialized)).toThrow( + 'Deserialization depth limit exceeded' + ); + }); + + test('should throw when nested monomorphic struct fields exceed maxDepth', () => { + const fory = new Fory({ maxDepth: 2 }); + const leaf = Type.struct({ + typeName: 'depth.leaf', + }, { + value: Type.int32(), + }); + const mid = Type.struct({ + typeName: 'depth.mid', + }, { + leaf, + }); + const root = Type.struct({ + typeName: 'depth.root', + }, { + mid, + }); + + const { serialize, deserialize } = fory.registerSerializer(root); + const serialized = serialize({ mid: { leaf: { value: 7 } } }); + + expect(() => deserialize(serialized)).toThrow( + 'Deserialization depth limit exceeded' + ); + }); + + test('should reset depth at start of each deserialization', () => { + const fory = new Fory({ maxDepth: 50 }); + const typeInfo = Type.struct({ + typeName: 'test.reset', + }, { + a: Type.int32(), + }); + + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + deserialize(serialize({ a: 1 })); + + // Depth will be reset at the start of resetRead() call + expect(fory.depth).toBe(0); + + deserialize(serialize({ a: 2 })); + expect(fory.depth).toBe(0); + }); + }); + + describe('cross-serialization depth limits', () => { + test('should allow serialize with high limit and deserialize with low limit', () => { + const serializeType = Type.struct({ + typeName: 'cross.test', + }, { + value: Type.int32(), + next: Type.struct({ + typeName: 'cross.inner', + }, { + innerValue: Type.int32(), + }).setNullable(true), + }); + + // Serialize with high limit + const forySerialize = new Fory({ maxDepth: 100 }); + const { serialize } = forySerialize.registerSerializer(serializeType); + + const data = { value: 1, next: { innerValue: 2 } }; + const serialized = serialize(data); + + // Deserialize with different instance + const foryDeserialize = new Fory({ maxDepth: 50 }); + const { deserialize } = foryDeserialize.registerSerializer(serializeType); + + const deserialized = deserialize(serialized); + expect(deserialized).toEqual(data); + }); + + test('should have independent depth tracking per Fory instance', () => { + const fory1 = new Fory({ maxDepth: 50 }); + const fory2 = new Fory({ maxDepth: 100 }); + + fory1.incReadDepth(); + fory1.incReadDepth(); + expect(fory1.depth).toBe(2); + + fory2.incReadDepth(); + expect(fory2.depth).toBe(1); + + // Both instances have independent depth counters + expect(fory1.depth).toBe(2); + expect(fory2.depth).toBe(1); + }); + }); + + describe('error scenarios', () => { + test('error message should include helpful suggestion', () => { + const fory = new Fory({ maxDepth: 2 }); + try { + for (let i = 0; i < 3; i++) { + fory.incReadDepth(); + } + throw new Error('Should have thrown'); + } catch (e) { + expect(e.message).toContain('increase maxDepth if needed'); + } + }); + + test('should recover after depth error when deserialization resets depth', () => { + const typeInfo = Type.struct({ + typeName: 'test.recovery', + }, { + a: Type.int32(), + }); + + const fory = new Fory({ maxDepth: 50 }); + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + + // First deserialization + let result = deserialize(serialize({ a: 1 })); + expect(result).toEqual({ a: 1 }); + expect(fory.depth).toBe(0); + + // Second deserialization should also work (depth reset) + result = deserialize(serialize({ a: 2 })); + expect(result).toEqual({ a: 2 }); + expect(fory.depth).toBe(0); + }); + }); + + describe('edge cases', () => { + test('should handle maxDepth exactly equal to required depth', () => { + const typeInfo = Type.struct({ + typeName: 'edge.exact', + }, { + a: Type.int32(), + }); + + const fory = new Fory({ maxDepth: 2 }); + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + // Should deserialize without error + const result = deserialize(serialize({ a: 42 })); + expect(result).toEqual({ a: 42 }); + }); + + test('should handle large maxDepth values', () => { + const fory = new Fory({ maxDepth: 10000 }); + expect(fory.maxDepth).toBe(10000); + }); + + test('should handle minimum valid maxDepth of 2', () => { + const typeInfo = Type.struct({ + typeName: 'edge.min', + }, { + a: Type.int32(), + }); + + const fory = new Fory({ maxDepth: 2 }); + expect(fory.maxDepth).toBe(2); + const { serialize, deserialize } = fory.registerSerializer(typeInfo); + // Should deserialize without error + const result = deserialize(serialize({ a: 42 })); + expect(result).toEqual({ a: 42 }); + }); + }); + + describe('configuration with other options', () => { + test('should work with refTracking enabled', () => { + const fory = new Fory({ + maxDepth: 50, + refTracking: true, + }); + expect(fory.maxDepth).toBe(50); + }); + + test('should work with compatible mode enabled', () => { + const fory = new Fory({ + maxDepth: 50, + compatible: true, + }); + expect(fory.maxDepth).toBe(50); + }); + + test('should work with useSliceString option', () => { + const fory = new Fory({ + maxDepth: 50, + useSliceString: true, + }); + expect(fory.maxDepth).toBe(50); + }); + + test('should work with all options combined', () => { + const fory = new Fory({ + maxDepth: 100, + refTracking: true, + compatible: true, + useSliceString: true, + }); + expect(fory.maxDepth).toBe(100); + }); + }); +});