diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index e58887ae..a4ef385d 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.1 + +* See [release notes](https://github.com/btwld/ack/releases/tag/v1.0.1) for details. + ## 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..3c7d650d 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -87,18 +87,26 @@ 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. [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. - static LazySchema lazy< - Boundary extends Object, - Runtime extends Object - >(String name, AckSchema Function() builder) { - return LazySchema(name, builder); + /// `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 = LazySchema.defaultMaxDepth, + }) { + 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..556350b9 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -9,9 +9,17 @@ 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. + final int maxDepth; + final AckSchema Function() _builder; late final AckSchema _target = _builder(); @@ -19,18 +27,25 @@ final class LazySchema LazySchema( this.name, this._builder, { + 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; + int get runtimeConstraintCount => _constraints.length + 1; @internal int get runtimeRefinementCount => _refinements.length; @@ -41,6 +56,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 +77,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 +95,36 @@ 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) { + var depth = 0; + for (SchemaContext? c = context; c != null; c = c.parent) { + if (identical(c.schema, this)) depth++; + } + if (depth <= maxDepth) return null; + + return SchemaResult.fail( + SchemaConstraintsError( + constraints: [ + ConstraintError( + constraint: _LazyMaxDepthConstraint(maxDepth), + message: 'Maximum recursion depth ($maxDepth) exceeded.', + context: {'depth': depth, 'maxDepth': maxDepth, 'lazyName': name}, + ), + ], + context: context, + ), + ); + } + @override LazySchema copyWith({ bool? isNullable, @@ -87,10 +132,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 +147,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 +160,7 @@ final class LazySchema return baseFieldsEqual(other) && name == other.name && + maxDepth == other.maxDepth && identical(_builder, other._builder); } @@ -117,6 +169,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..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.0.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 1f11b9eb..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, }); }); @@ -314,6 +314,89 @@ 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 maxDepth to LazySchema.defaultMaxDepth', () { + final target = Ack.object({'name': Ack.string()}); + final lazy = Ack.lazy('Category', () => target); + + 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', () { + 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