diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 69bdfd7a..ad83adde 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -8,6 +8,8 @@ * Validate discriminated codec output against its selected map branch or a matching typed runtime branch, including required fields and refinements, without decoding runtime values again or adding encode callback passes. +* Reject duration millisecond inputs that overflow the platform's `Duration` + representation instead of silently decoding to a different duration. * Expose the shared `deepUnmodifiableCopy` helper for generated immutable constructors. It snapshots nested collections without validating data or changing opaque noncollection objects. diff --git a/packages/ack/lib/src/ack.dart b/packages/ack/lib/src/ack.dart index 1f0fc756..15774f87 100644 --- a/packages/ack/lib/src/ack.dart +++ b/packages/ack/lib/src/ack.dart @@ -232,6 +232,9 @@ final class Ack { /// Creates a bidirectional duration codec between integer milliseconds and /// `Duration` values. /// + /// Inputs that cannot be represented by `Duration` on the current platform + /// without changing their millisecond value are rejected. + /// /// Runtime invariant: the `Duration` must be a whole number of /// milliseconds (sub-millisecond precision is rejected to avoid silent /// truncation on encode). @@ -271,7 +274,13 @@ bool _isWholeMillisecondDuration(Duration value) { } Duration _decodeDuration(int milliseconds) { - return Duration(milliseconds: milliseconds); + final duration = Duration(milliseconds: milliseconds); + if (duration.inMilliseconds != milliseconds) { + throw const FormatException( + 'Milliseconds cannot be represented by Duration without loss.', + ); + } + return duration; } int _encodeDuration(Duration value) => value.inMilliseconds; diff --git a/packages/ack/test/schemas/duration_range_test.dart b/packages/ack/test/schemas/duration_range_test.dart new file mode 100644 index 00000000..6fabb4a8 --- /dev/null +++ b/packages/ack/test/schemas/duration_range_test.dart @@ -0,0 +1,78 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +final _usesJavaScriptNumberRepresentation = [1e20].single is int; + +void main() { + test('preserves milliseconds within the exact range on VM and web', () { + final schema = Ack.duration(); + for (final milliseconds in [0, 1, -1, 9007199254740, -9007199254740]) { + final parsed = schema.parse(milliseconds)!; + expect(parsed.inMilliseconds, milliseconds); + expect(schema.encode(parsed), milliseconds); + } + }); + + test('retains lossless JSON integer normalization', () { + final schema = Ack.duration(); + expect(schema.encode(schema.parse(1500.0)), 1500); + expect(schema.encode(schema.parse(-1500.0)), -1500); + expect(schema.safeParse(1.5).isFail, isTrue); + }); + + test('any accepted large input must round-trip without corruption', () { + final schema = Ack.duration(); + for (final text in [ + '9007199254740991', + '-9007199254740991', + '9223372036854775', + '-9223372036854775', + '9223372036854776', + '-9223372036854776', + '9223372036854775807', + '-9223372036854775808', + ]) { + final milliseconds = int.parse(text); + final result = schema.safeParse(milliseconds); + if (result.isOk) { + final parsed = result.getOrNull()!; + expect(parsed.inMilliseconds, milliseconds, reason: text); + expect(schema.encode(parsed), milliseconds, reason: text); + } + } + }); + + group( + 'native signed 64-bit Duration bounds', + () { + test('accepts both whole-millisecond supported edges', () { + final schema = Ack.duration(); + final edge = (BigInt.parse('9223372036854775807') ~/ BigInt.from(1000)) + .toInt(); + for (final milliseconds in [edge, -edge]) { + final parsed = schema.parse(milliseconds)!; + expect(parsed.inMilliseconds, milliseconds); + expect(schema.encode(parsed), milliseconds); + } + }); + + test('rejects values outside the representable microsecond range', () { + final schema = Ack.duration(); + for (final text in [ + '9223372036854776', + '-9223372036854776', + '9223372036854775807', + '-9223372036854775808', + ]) { + final result = schema.safeParse(int.parse(text)); + expect(result.isFail, isTrue, reason: text); + expect(result.getError(), isA(), reason: text); + expect(result.getError().cause, isA(), reason: text); + } + }); + }, + skip: _usesJavaScriptNumberRepresentation + ? 'JavaScript does not use the native signed 64-bit representation.' + : false, + ); +}