From 25b4f64e76e4b342031b20322465d800280b510a Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 4 Jul 2026 16:29:47 -0400 Subject: [PATCH 1/2] Add lazy schema max depth --- packages/ack/CHANGELOG.md | 9 +++ packages/ack/lib/src/ack.dart | 20 +++-- packages/ack/lib/src/schemas/lazy_schema.dart | 72 +++++++++++++++++- packages/ack/pubspec.yaml | 2 +- .../ack/test/schemas/lazy_schema_test.dart | 73 +++++++++++++++++++ 5 files changed, 164 insertions(+), 12 deletions(-) diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index e58887ae..432e9393 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,3 +1,12 @@ +## 1.1.0 + +### Added + +* Add optional `maxDepth` support to `Ack.lazy` / `LazySchema`, letting + recursive schemas fail parse, runtime validation, and encode paths with a + bounded schema constraint error instead of recursing without a package-level + cap. + ## 1.0.0 * See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.0) for details. diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 27e4a801..2bdb0357 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -87,18 +87,22 @@ final class Ack { /// Creates a schema reference that is resolved lazily on first use. /// - /// The [builder] is called once and memoized. Two `Ack.lazy` instances are - /// equal only when their `builder` closure is the same reference -- pulling - /// the closure into a `final` variable lets two calls share equality. + /// The [builder] is called once and memoized. Set [maxDepth] to fail recursive + /// parse, runtime validation, or encode paths before they can grow without + /// bound. Two `Ack.lazy` instances are equal only when their `builder` closure + /// is the same reference -- pulling the closure into a `final` variable lets + /// two calls share equality. /// /// `toJsonSchema()` and `toSchemaModel()` export lazy references through /// recursive `definitions`/`$ref` JSON Schema definitions. Bare or wrapped /// `Ack.lazy` schemas cannot be used as discriminated union branches. - static LazySchema lazy< - Boundary extends Object, - Runtime extends Object - >(String name, AckSchema Function() builder) { - return LazySchema(name, builder); + static LazySchema + lazy( + String name, + AckSchema Function() builder, { + int? maxDepth, + }) { + return LazySchema(name, builder, maxDepth: maxDepth); } /// Creates a schema for a specific Dart instance type [T], with [T] as diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index c6cd6e62..988e64ad 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -12,6 +12,12 @@ final class LazySchema /// Human-readable name for this deferred schema reference. final String name; + /// Maximum number of times this lazy schema may appear in its active context + /// chain before parsing, runtime validation, or encoding fails. + /// + /// A value of `null` leaves recursion depth unlimited. + final int? maxDepth; + final AckSchema Function() _builder; late final AckSchema _target = _builder(); @@ -19,6 +25,7 @@ final class LazySchema LazySchema( this.name, this._builder, { + this.maxDepth, super.isNullable, super.isOptional, super.description, @@ -30,7 +37,8 @@ final class LazySchema AckSchema get target => _target; @internal - int get runtimeConstraintCount => _constraints.length; + int get runtimeConstraintCount => + _constraints.length + (maxDepth == null ? 0 : 1); @internal int get runtimeRefinementCount => _refinements.length; @@ -41,6 +49,9 @@ final class LazySchema final nullResult = handleNullInput(value, context); if (nullResult != null) return nullResult; + final depthResult = _checkMaxDepth(context); + if (depthResult != null) return depthResult; + final result = _target.parseWithContext(value, context); if (result.isFail) return SchemaResult.fail(result.getError()); @@ -59,6 +70,9 @@ final class LazySchema final nullResult = handleNullInput(value, context); if (nullResult != null) return nullResult; + final depthResult = _checkMaxDepth(context); + if (depthResult != null) return depthResult; + final result = _target.validateRuntimeWithContext(value, context); if (result.isFail) return SchemaResult.fail(result.getError()); @@ -74,12 +88,39 @@ final class LazySchema Runtime value, SchemaContext context, ) { + final depthResult = _checkMaxDepth(context); + if (depthResult != null) return depthResult; + final ownChecked = applyConstraintsAndRefinements(value, context); if (ownChecked.isFail) return SchemaResult.fail(ownChecked.getError()); return _target.encodeWithContext(ownChecked.getOrThrow()!, context); } + SchemaResult? _checkMaxDepth(SchemaContext context) { + final limit = maxDepth; + if (limit == null) return null; + + var depth = 0; + for (SchemaContext? c = context; c != null; c = c.parent) { + if (identical(c.schema, this)) depth++; + } + if (depth <= limit) return null; + + return SchemaResult.fail( + SchemaConstraintsError( + constraints: [ + ConstraintError( + constraint: _LazyMaxDepthConstraint(limit), + message: 'Maximum recursion depth ($limit) exceeded.', + context: {'depth': depth, 'maxDepth': limit, 'lazyName': name}, + ), + ], + context: context, + ), + ); + } + @override LazySchema copyWith({ bool? isNullable, @@ -87,10 +128,12 @@ final class LazySchema String? description, List>? constraints, List>? refinements, + int? maxDepth, }) { return LazySchema( name, _builder, + maxDepth: maxDepth ?? this.maxDepth, isNullable: isNullable ?? this.isNullable, isOptional: isOptional ?? this.isOptional, description: description ?? this.description, @@ -100,7 +143,11 @@ final class LazySchema } @override - Map toMap() => {...super.toMap(), 'name': name}; + Map toMap() => { + ...super.toMap(), + 'name': name, + 'maxDepth': maxDepth, + }; @override bool operator ==(Object other) { @@ -109,6 +156,7 @@ final class LazySchema return baseFieldsEqual(other) && name == other.name && + maxDepth == other.maxDepth && identical(_builder, other._builder); } @@ -117,6 +165,24 @@ final class LazySchema @override int get hashCode { - return Object.hash(baseFieldsHashCode, name, identityHashCode(_builder)); + return Object.hash( + baseFieldsHashCode, + name, + maxDepth, + identityHashCode(_builder), + ); } } + +final class _LazyMaxDepthConstraint extends Constraint { + _LazyMaxDepthConstraint(this.maxDepth) + : super( + constraintKey: 'lazy_max_depth', + description: 'Lazy recursion depth must not exceed $maxDepth.', + ); + + final int maxDepth; + + @override + Map toMap() => {...super.toMap(), 'maxDepth': maxDepth}; +} diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index 28d88910..b94181ea 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -1,6 +1,6 @@ name: ack description: A simple validation library for Dart -version: 1.0.0 +version: 1.1.0 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues homepage: https://docs.page/btwld/ack diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index 1f11b9eb..6f1a353c 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -314,6 +314,79 @@ void main() { // on top of that, inflating this count. Locks in the fixed call profile. expect(calls, 15); }); + + group('maxDepth', () { + test('allows input at the cap and fails one level deeper', () { + const maxDepth = 3; + late final ObjectSchema categorySchema; + final child = Ack.lazy( + 'Category', + () => categorySchema, + maxDepth: maxDepth, + ).nullable().optional(); + categorySchema = Ack.object({'name': Ack.string(), 'child': child}); + + final atLimit = _nestedCategoryJson(maxDepth); + final tooDeep = _nestedCategoryJson(maxDepth + 1); + + expect(categorySchema.safeParse(atLimit).isOk, isTrue); + expect(categorySchema.safeParse(tooDeep).isFail, isTrue); + expect(categorySchema.safeEncode(atLimit).isOk, isTrue); + expect(categorySchema.safeEncode(tooDeep).isFail, isTrue); + }); + + test('defaults to unlimited recursion depth', () { + late final ObjectSchema categorySchema; + final child = Ack.lazy( + 'Category', + () => categorySchema, + ).nullable().optional(); + categorySchema = Ack.object({'name': Ack.string(), 'child': child}); + + expect(categorySchema.safeParse(_nestedCategoryJson(12)).isOk, isTrue); + }); + + test('preserves maxDepth through fluent copies', () { + final target = Ack.object({'name': Ack.string()}); + final lazy = Ack.lazy( + 'Category', + () => target, + maxDepth: 2, + ); + + expect(lazy.maxDepth, 2); + expect(lazy.nullable().optional().maxDepth, 2); + }); + + test('includes maxDepth in equality and hashCode', () { + final target = Ack.object({'name': Ack.string()}); + AckSchema builder() => target; + + final uncapped = Ack.lazy('Category', builder); + final capped = Ack.lazy( + 'Category', + builder, + maxDepth: 2, + ); + final matchingCap = Ack.lazy( + 'Category', + builder, + maxDepth: 2, + ); + + expect(capped, matchingCap); + expect(capped.hashCode, matchingCap.hashCode); + expect(capped, isNot(uncapped)); + }); + }); +} + +JsonMap _nestedCategoryJson(int childDepth) { + JsonMap node = {'name': 'node-$childDepth'}; + for (var depth = childDepth - 1; depth >= 0; depth--) { + node = {'name': 'node-$depth', 'child': node}; + } + return node; } final class _TestJsonSchemaKeywordConstraint From 70dbd236c481c71521834a8e5b254346e29d0698 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Sat, 4 Jul 2026 16:54:00 -0400 Subject: [PATCH 2/2] Cap lazy schema recursion depth by default Make LazySchema.maxDepth non-nullable with a 100-level default cap instead of unlimited recursion, validate it's >= 1 at construction, and bump the package to 1.0.1 with a link-only changelog entry. --- packages/ack/CHANGELOG.md | 9 ++---- packages/ack/lib/src/ack.dart | 18 +++++++---- packages/ack/lib/src/schemas/lazy_schema.dart | 32 +++++++++++-------- packages/ack/pubspec.yaml | 2 +- .../ack/test/schemas/lazy_schema_test.dart | 28 ++++++++++------ 5 files changed, 51 insertions(+), 38 deletions(-) diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 432e9393..a4ef385d 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,11 +1,6 @@ -## 1.1.0 +## 1.0.1 -### Added - -* Add optional `maxDepth` support to `Ack.lazy` / `LazySchema`, letting - recursive schemas fail parse, runtime validation, and encode paths with a - bounded schema constraint error instead of recursing without a package-level - cap. +* See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.1) for details. ## 1.0.0 diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 2bdb0357..3c7d650d 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -87,20 +87,24 @@ final class Ack { /// Creates a schema reference that is resolved lazily on first use. /// - /// The [builder] is called once and memoized. Set [maxDepth] to fail recursive - /// parse, runtime validation, or encode paths before they can grow without - /// bound. Two `Ack.lazy` instances are equal only when their `builder` closure - /// is the same reference -- pulling the closure into a `final` variable lets - /// two calls share equality. + /// The [builder] is called once and memoized. [maxDepth] bounds how many + /// times this lazy schema may recur in its active context chain before + /// parsing, runtime validation, or encoding fails; it defaults to + /// [LazySchema.defaultMaxDepth] and must be at least `1`. Two `Ack.lazy` + /// instances are equal only when their `builder` closure is the same + /// reference -- pulling the closure into a `final` variable lets two calls + /// share equality. /// /// `toJsonSchema()` and `toSchemaModel()` export lazy references through /// recursive `definitions`/`$ref` JSON Schema definitions. Bare or wrapped - /// `Ack.lazy` schemas cannot be used as discriminated union branches. + /// `Ack.lazy` schemas cannot be used as discriminated union branches. The + /// `maxDepth` check is a runtime-only constraint that cannot be expressed + /// through a `$ref`, so exported schema models warn that it was omitted. static LazySchema lazy( String name, AckSchema Function() builder, { - int? maxDepth, + int maxDepth = LazySchema.defaultMaxDepth, }) { return LazySchema(name, builder, maxDepth: maxDepth); } diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index 988e64ad..556350b9 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -9,14 +9,16 @@ part of 'schema.dart'; final class LazySchema extends AckSchema with FluentSchema> { + /// Default recursion cap applied to `Ack.lazy` schemas that do not + /// specify their own [maxDepth]. + static const defaultMaxDepth = 100; + /// Human-readable name for this deferred schema reference. final String name; /// Maximum number of times this lazy schema may appear in its active context /// chain before parsing, runtime validation, or encoding fails. - /// - /// A value of `null` leaves recursion depth unlimited. - final int? maxDepth; + final int maxDepth; final AckSchema Function() _builder; @@ -25,20 +27,25 @@ final class LazySchema LazySchema( this.name, this._builder, { - this.maxDepth, + this.maxDepth = defaultMaxDepth, super.isNullable, super.isOptional, super.description, super.constraints, super.refinements, - }); + }) { + if (maxDepth < 1) { + throw ArgumentError.value(maxDepth, 'maxDepth', 'Must be >= 1.'); + } + } @internal AckSchema get target => _target; + /// Count of runtime-only constraints, including the always-present + /// max-depth check that every `Ack.lazy` schema enforces. @internal - int get runtimeConstraintCount => - _constraints.length + (maxDepth == null ? 0 : 1); + int get runtimeConstraintCount => _constraints.length + 1; @internal int get runtimeRefinementCount => _refinements.length; @@ -98,22 +105,19 @@ final class LazySchema } SchemaResult? _checkMaxDepth(SchemaContext context) { - final limit = maxDepth; - if (limit == null) return null; - var depth = 0; for (SchemaContext? c = context; c != null; c = c.parent) { if (identical(c.schema, this)) depth++; } - if (depth <= limit) return null; + if (depth <= maxDepth) return null; return SchemaResult.fail( SchemaConstraintsError( constraints: [ ConstraintError( - constraint: _LazyMaxDepthConstraint(limit), - message: 'Maximum recursion depth ($limit) exceeded.', - context: {'depth': depth, 'maxDepth': limit, 'lazyName': name}, + constraint: _LazyMaxDepthConstraint(maxDepth), + message: 'Maximum recursion depth ($maxDepth) exceeded.', + context: {'depth': depth, 'maxDepth': maxDepth, 'lazyName': name}, ), ], context: context, diff --git a/packages/ack/pubspec.yaml b/packages/ack/pubspec.yaml index b94181ea..a6112865 100644 --- a/packages/ack/pubspec.yaml +++ b/packages/ack/pubspec.yaml @@ -1,6 +1,6 @@ name: ack description: A simple validation library for Dart -version: 1.1.0 +version: 1.0.1 repository: https://github.com/btwld/ack issue_tracker: https://github.com/btwld/ack/issues homepage: https://docs.page/btwld/ack diff --git a/packages/ack/test/schemas/lazy_schema_test.dart b/packages/ack/test/schemas/lazy_schema_test.dart index 6f1a353c..391baedf 100644 --- a/packages/ack/test/schemas/lazy_schema_test.dart +++ b/packages/ack/test/schemas/lazy_schema_test.dart @@ -212,7 +212,7 @@ void main() { expect(child.warnings, hasLength(1)); expect(child.warnings.single.code, 'lazy_runtime_checks_not_export_safe'); expect(child.warnings.single.context, { - 'constraintCount': 0, + 'constraintCount': 1, 'refinementCount': 1, }); }); @@ -335,15 +335,25 @@ void main() { expect(categorySchema.safeEncode(tooDeep).isFail, isTrue); }); - test('defaults to unlimited recursion depth', () { - late final ObjectSchema categorySchema; - final child = Ack.lazy( - 'Category', - () => categorySchema, - ).nullable().optional(); - categorySchema = Ack.object({'name': Ack.string(), 'child': child}); + test('defaults maxDepth to LazySchema.defaultMaxDepth', () { + final target = Ack.object({'name': Ack.string()}); + final lazy = Ack.lazy('Category', () => target); - expect(categorySchema.safeParse(_nestedCategoryJson(12)).isOk, isTrue); + expect(lazy.maxDepth, LazySchema.defaultMaxDepth); + }); + + test('throws for maxDepth values below 1', () { + final target = Ack.object({'name': Ack.string()}); + + expect( + () => Ack.lazy('Category', () => target, maxDepth: 0), + throwsA(isA()), + ); + expect( + () => + Ack.lazy('Category', () => target, maxDepth: -1), + throwsA(isA()), + ); }); test('preserves maxDepth through fluent copies', () {