diff --git a/packages/relic/test/headers/header_test.dart b/packages/relic/test/headers/header_test.dart index 4218bc1a..ff3a6010 100644 --- a/packages/relic/test/headers/header_test.dart +++ b/packages/relic/test/headers/header_test.dart @@ -190,16 +190,22 @@ void main() { Headers.accessControlAllowOrigin, Headers.accessControlExposeHeaders, Headers.accessControlRequestHeaders, + Headers.cacheControl, + Headers.connection, Headers.contentDisposition, + Headers.contentEncoding, Headers.contentLanguage, Headers.contentLocation, Headers.contentSecurityPolicy, + Headers.expect, + Headers.from, Headers.host, Headers.location, Headers.origin, Headers.permissionsPolicy, Headers.referer, Headers.server, + Headers.setCookie, Headers.te, Headers.trailer, Headers.upgrade, @@ -602,7 +608,7 @@ void main() { Headers.accessControlAllowOrigin, (final h) => h.accessControlAllowOrigin = AccessControlAllowOriginHeader.origin( - origin: Uri.parse('https://example.com'), + origin: Origin.parse('https://example.com'), ), ), ( @@ -703,7 +709,7 @@ void main() { ), ( Headers.contentRange, - (final h) => h.contentRange = ContentRangeHeader(), + (final h) => h.contentRange = ContentRangeHeader(size: 1234), ), ( Headers.contentSecurityPolicy, @@ -739,7 +745,7 @@ void main() { (Headers.expires, (final h) => h.expires = DateTime.utc(2025, 9, 23)), ( Headers.from, - (final h) => h.from = FromHeader.emails(['info@serverpod.com']), + (final h) => h.from = const FromHeader('info@serverpod.com'), ), (Headers.host, (final h) => h.host = HostHeader('www.example.com', 80)), ( diff --git a/packages/relic/test/headers/typed/accept_encoding_header_test.dart b/packages/relic/test/headers/typed/accept_encoding_header_test.dart index cee83a23..30d7a418 100644 --- a/packages/relic/test/headers/typed/accept_encoding_header_test.dart +++ b/packages/relic/test/headers/typed/accept_encoding_header_test.dart @@ -37,24 +37,24 @@ void main() { }, ); - test('when an Accept-Encoding header with invalid quality values is passed ' - 'then the server responds with a bad request including a message that ' - 'states the quality value is invalid', () async { - expect( - getServerRequestHeaders( + test( + 'when an Accept-Encoding header with a malformed quality value is ' + 'passed then the weight defaults to 1.0 instead of being rejected', + () async { + final headers = await getServerRequestHeaders( server: server, headers: {'accept-encoding': 'gzip;q=abc'}, touchHeaders: (final h) => h.acceptEncoding, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid quality value'), - ), - ), - ); - }); + ); + + expect( + headers.acceptEncoding?.encodings + .map((final e) => e.quality) + .toList(), + equals([1.0]), + ); + }, + ); test( 'when an Accept-Encoding header with wildcard (*) and other encodings is ' @@ -349,18 +349,25 @@ void main() { }); group( - 'when Accept-Encoding headers with invalid quality values are passed', + 'when Accept-Encoding headers with a malformed quality value are passed', () { - test('then it should return null', () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (_) {}, - headers: {'accept-encoding': 'gzip;q=abc, deflate, br'}, - ); - - expect(Headers.acceptEncoding[headers].valueOrNullIfInvalid, isNull); - expect(() => headers.acceptEncoding, throwsInvalidHeader); - }); + test( + 'then the header still parses and the weight defaults to 1.0', + () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (_) {}, + headers: {'accept-encoding': 'gzip;q=abc, deflate, br'}, + ); + + expect( + headers.acceptEncoding?.encodings + .map((final e) => e.quality) + .toList(), + equals([1.0, 1.0, 1.0]), + ); + }, + ); }, ); }); diff --git a/packages/relic/test/headers/typed/accept_header_test.dart b/packages/relic/test/headers/typed/accept_header_test.dart index 7c73e6a5..2ee0fa57 100644 --- a/packages/relic/test/headers/typed/accept_header_test.dart +++ b/packages/relic/test/headers/typed/accept_header_test.dart @@ -37,27 +37,18 @@ void main() { }, ); - test( - 'when an Accept header with invalid quality value is passed then the server ' - 'should respond with a bad request including a message that states the ' - 'quality value is invalid', - () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.accept, - headers: {'accept': 'text/html;q=abc'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid quality value'), - ), - ), - ); - }, - ); + test('when an Accept header with a malformed quality value is passed ' + 'then the weight defaults to 1.0 instead of being rejected', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.accept, + headers: {'accept': 'text/html;q=abc'}, + ); + + final mediaRanges = headers.accept?.mediaRanges; + expect(mediaRanges?.length, equals(1)); + expect(mediaRanges?[0].quality, equals(1.0)); + }); test('when an Accept header with an invalid value is passed ' 'then the server does not respond with a bad request if the headers ' @@ -65,7 +56,7 @@ void main() { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'accept': 'text/html;q=abc'}, + headers: {'accept': 'invalid'}, ); expect(headers, isNotNull); @@ -146,25 +137,22 @@ void main() { group('when multiple Accept media ranges are passed', () { test( - 'with invalid quality values are passed then the server should respond with a bad request ' - 'including a message that states the quality value is invalid', + 'with malformed quality values then those weights default to 1.0 and ' + 'valid weights are preserved', () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.accept, - headers: { - 'accept': 'text/html;q=test, application/json;q=abc, */*;q=0.5', - }, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid quality value'), - ), - ), + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.accept, + headers: { + 'accept': 'text/html;q=test, application/json;q=abc, */*;q=0.5', + }, ); + + final mediaRanges = headers.accept?.mediaRanges; + expect(mediaRanges?.length, equals(3)); + expect(mediaRanges?[0].quality, equals(1.0)); + expect(mediaRanges?[1].quality, equals(1.0)); + expect(mediaRanges?[2].quality, equals(0.5)); }, ); test( @@ -219,12 +207,12 @@ void main() { tearDown(() => server.close()); - group('when an Accept header with invalid quality value is passed', () { + group('when an Accept header with an invalid value is passed', () { test('then it should return null', () async { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'accept': 'text/html;q=abc'}, + headers: {'accept': 'invalid'}, ); expect(Headers.accept[headers].valueOrNullIfInvalid, isNull); diff --git a/packages/relic/test/headers/typed/accept_language_test.dart b/packages/relic/test/headers/typed/accept_language_test.dart index fca24157..6d84c6b1 100644 --- a/packages/relic/test/headers/typed/accept_language_test.dart +++ b/packages/relic/test/headers/typed/accept_language_test.dart @@ -38,24 +38,24 @@ void main() { }, ); - test('when an Accept-Language header with invalid quality values is passed ' - 'then the server responds with a bad request including a message that ' - 'states the quality value is invalid', () async { - expect( - getServerRequestHeaders( + test( + 'when an Accept-Language header with a malformed quality value is ' + 'passed then the weight defaults to 1.0 instead of being rejected', + () async { + final headers = await getServerRequestHeaders( server: server, touchHeaders: (final h) => h.acceptLanguage, headers: {'accept-language': 'en;q=abc'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid quality value'), - ), - ), - ); - }); + ); + + expect( + headers.acceptLanguage?.languages + .map((final e) => e.quality) + .toList(), + equals([1.0]), + ); + }, + ); test( 'when an Accept-Language header with wildcard (*) and other languages is ' @@ -373,18 +373,25 @@ void main() { }); group( - 'when Accept-Language headers with invalid quality values are passed', + 'when Accept-Language headers with a malformed quality value are passed', () { - test('then it should return null', () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (_) {}, - headers: {'accept-language': 'en;q=abc, fr, de'}, - ); - - expect(Headers.acceptLanguage[headers].valueOrNullIfInvalid, isNull); - expect(() => headers.acceptLanguage, throwsInvalidHeader); - }); + test( + 'then the header still parses and the weight defaults to 1.0', + () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (_) {}, + headers: {'accept-language': 'en;q=abc, fr, de'}, + ); + + expect( + headers.acceptLanguage?.languages + .map((final e) => e.quality) + .toList(), + equals([1.0, 1.0, 1.0]), + ); + }, + ); }, ); }); diff --git a/packages/relic/test/headers/typed/accept_ranges_header_test.dart b/packages/relic/test/headers/typed/accept_ranges_header_test.dart index 28aca27d..df5744e2 100644 --- a/packages/relic/test/headers/typed/accept_ranges_header_test.dart +++ b/packages/relic/test/headers/typed/accept_ranges_header_test.dart @@ -58,7 +58,24 @@ void main() { headers: {'accept-ranges': 'bytes'}, ); - expect(headers.acceptRanges?.rangeUnit, equals('bytes')); + expect(headers.acceptRanges?.rangeUnits, equals(['bytes'])); + expect(headers.acceptRanges?.isBytes, isTrue); + }, + ); + + test( + 'when multiple range units are passed then they are all parsed', + () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.acceptRanges, + headers: {'accept-ranges': 'bytes, custom-unit'}, + ); + + expect( + headers.acceptRanges?.rangeUnits, + equals(['bytes', 'custom-unit']), + ); expect(headers.acceptRanges?.isBytes, isTrue); }, ); @@ -72,7 +89,7 @@ void main() { headers: {'accept-ranges': 'none'}, ); - expect(headers.acceptRanges?.rangeUnit, equals('none')); + expect(headers.acceptRanges?.rangeUnits, equals(['none'])); expect(headers.acceptRanges?.isNone, isTrue); }, ); diff --git a/packages/relic/test/headers/typed/access_control_allow_headers_header_test.dart b/packages/relic/test/headers/typed/access_control_allow_headers_header_test.dart index 80ee8fdd..f773f5e7 100644 --- a/packages/relic/test/headers/typed/access_control_allow_headers_header_test.dart +++ b/packages/relic/test/headers/typed/access_control_allow_headers_header_test.dart @@ -87,7 +87,7 @@ void main() { expect(allowedHeaders?.length, equals(2)); expect( allowedHeaders, - containsAll(['Content-Type', 'X-Custom-Header']), + containsAll(['content-type', 'x-custom-header']), ); }, ); diff --git a/packages/relic/test/headers/typed/access_control_allow_origin_header_test.dart b/packages/relic/test/headers/typed/access_control_allow_origin_header_test.dart index 9abbbb45..17ae3e7e 100644 --- a/packages/relic/test/headers/typed/access_control_allow_origin_header_test.dart +++ b/packages/relic/test/headers/typed/access_control_allow_origin_header_test.dart @@ -39,9 +39,8 @@ void main() { ); test( - 'when a Access-Control-Allow-Origin header with an invalid URI is passed ' - 'then the server responds with a bad request including a message that ' - 'states the URI is invalid', + 'when a Access-Control-Allow-Origin header with an invalid origin is passed ' + 'then the server responds with a bad request', () async { expect( getServerRequestHeaders( @@ -49,21 +48,14 @@ void main() { touchHeaders: (final h) => h.accessControlAllowOrigin, headers: {'access-control-allow-origin': 'ht!tp://invalid-url'}, ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid URI format'), - ), - ), + throwsA(isA()), ); }, ); test( 'when an Access-Control-Allow-Origin header with an invalid port is passed ' - 'then the server responds with a bad request including a message that ' - 'states the URI is invalid', + 'then the server responds with a bad request', () async { expect( getServerRequestHeaders( @@ -73,17 +65,30 @@ void main() { 'access-control-allow-origin': 'https://example.com:test', }, ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid URI format'), - ), - ), + throwsA(isA()), ); }, ); + test('when an Access-Control-Allow-Origin header has a trailing slash ' + 'then the server responds with a bad request ' + '(origins must not include paths per the Fetch spec)', () async { + expect( + getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.accessControlAllowOrigin, + headers: {'access-control-allow-origin': 'https://example.com/'}, + ), + throwsA( + isA().having( + (final e) => e.message, + 'message', + contains('origin must not include path'), + ), + ), + ); + }); + test( 'when a Access-Control-Allow-Origin header with an invalid value is passed ' 'then the server does not respond with a bad request if the headers ' @@ -100,7 +105,7 @@ void main() { ); test( - 'when a Access-Control-Allow-Origin header with a valid URI origin is passed ' + 'when a Access-Control-Allow-Origin header with a valid origin is passed ' 'then it should parse correctly', () async { final headers = await getServerRequestHeaders( @@ -111,13 +116,13 @@ void main() { expect( headers.accessControlAllowOrigin?.origin, - equals(Uri.parse('https://example.com')), + equals(Origin.parse('https://example.com')), ); }, ); test( - 'when a Access-Control-Allow-Origin header with a valid URI origin and port is passed ' + 'when a Access-Control-Allow-Origin header with a valid origin and port is passed ' 'then it should parse correctly', () async { final headers = await getServerRequestHeaders( @@ -126,26 +131,25 @@ void main() { headers: {'access-control-allow-origin': 'https://example.com:8080'}, ); - expect(headers.accessControlAllowOrigin?.origin?.port, equals(8080)); + final origin = headers.accessControlAllowOrigin?.origin; + expect(origin, isA()); + expect((origin as TupleOrigin).host.port, equals(8080)); }, ); - test( - 'when a Access-Control-Allow-Origin header with a valid URI origin with ' - 'spaces is passed then it should parse correctly', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.accessControlAllowOrigin, - headers: {'access-control-allow-origin': ' https://example.com '}, - ); - - expect( - headers.accessControlAllowOrigin?.origin, - equals(Uri.parse('https://example.com')), - ); - }, - ); + test('when a Access-Control-Allow-Origin header with a valid origin with ' + 'spaces is passed then it should parse correctly', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.accessControlAllowOrigin, + headers: {'access-control-allow-origin': ' https://example.com '}, + ); + + expect( + headers.accessControlAllowOrigin?.origin, + equals(Origin.parse('https://example.com')), + ); + }); test( 'when a Access-Control-Allow-Origin header with a wildcard (*) is passed ' @@ -162,6 +166,20 @@ void main() { }, ); + test('when a Access-Control-Allow-Origin header is the literal "null" ' + 'then it parses as the opaque-origin sentinel', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.accessControlAllowOrigin, + headers: {'access-control-allow-origin': 'null'}, + ); + + expect( + headers.accessControlAllowOrigin?.origin, + same(OpaqueOrigin.instance), + ); + }); + test( 'when no Access-Control-Allow-Origin header is passed then it should return null', () async { diff --git a/packages/relic/test/headers/typed/access_control_expose_headers_header_test.dart b/packages/relic/test/headers/typed/access_control_expose_headers_header_test.dart index eed35916..528e1714 100644 --- a/packages/relic/test/headers/typed/access_control_expose_headers_header_test.dart +++ b/packages/relic/test/headers/typed/access_control_expose_headers_header_test.dart @@ -88,7 +88,7 @@ void main() { expect( headers.accessControlExposeHeaders?.headers, - equals(['X-Custom-Header']), + equals(['x-custom-header']), ); }, ); @@ -133,7 +133,7 @@ void main() { expect( headers.accessControlExposeHeaders?.headers, - equals(['X-Custom-Header', 'X-Another-Header']), + equals(['x-custom-header', 'x-another-header']), ); }); @@ -149,7 +149,7 @@ void main() { expect( headers.accessControlExposeHeaders?.headers, - equals(['X-Custom-Header', 'X-Another-Header']), + equals(['x-custom-header', 'x-another-header']), ); }); }); diff --git a/packages/relic/test/headers/typed/authorization_header_test.dart b/packages/relic/test/headers/typed/authorization_header_test.dart index fed6605c..c17ccff4 100644 --- a/packages/relic/test/headers/typed/authorization_header_test.dart +++ b/packages/relic/test/headers/typed/authorization_header_test.dart @@ -81,7 +81,7 @@ void main() { isA().having( (final e) => e.message, 'message', - contains('Invalid bearer prefix'), + contains('Invalid Bearer prefix'), ), ), ); @@ -474,7 +474,11 @@ void main() { variants: [ ('valid', 'valid:Password', returnsNormally), // : in password is legal ('invalid:Username', 'validPassword', throwsFormatException), - ('validUsername', '', throwsFormatException), // empty password + ( + 'validUsername', + '', + returnsNormally, + ), // empty password is legal (RFC 7617) ('', 'validPassword', throwsFormatException), // empty username ], (final v) => // diff --git a/packages/relic/test/headers/typed/cache_control_header_test.dart b/packages/relic/test/headers/typed/cache_control_header_test.dart index 6584d7ef..b69976e2 100644 --- a/packages/relic/test/headers/typed/cache_control_header_test.dart +++ b/packages/relic/test/headers/typed/cache_control_header_test.dart @@ -38,42 +38,44 @@ void main() { }, ); - test( - 'when an invalid Cache-Control directive is passed then the server responds ' - 'with a bad request including a message that states the directive is invalid', - () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.cacheControl, - headers: {'cache-control': 'invalid-directive'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid directive'), - ), - ), - ); - }, - ); + test('when an unknown Cache-Control directive is passed ' + 'then it is ignored (RFC 9111 5.2 extension directives)', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.cacheControl, + headers: {'cache-control': 'invalid-directive'}, + ); + + expect(headers.cacheControl?.maxAge, isNull); + expect(headers.cacheControl?.noCache, isFalse); + }); + + test('when a Cache-Control header mixes a known and an unknown directive ' + 'then the known one is kept and the unknown one is ignored', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.cacheControl, + headers: {'cache-control': 'public, invalid-directive'}, + ); + + expect(headers.cacheControl?.publicCache, isTrue); + }); test( - 'when a Cache-Control header with an invalid directive is passed then the server responds ' - 'with a bad request including a message that states the directive is invalid', + 'when a Cache-Control header with both public and private is passed then the server responds ' + 'with a bad request including a message that states the directives cannot be both public and private', () async { expect( getServerRequestHeaders( server: server, touchHeaders: (final h) => h.cacheControl, - headers: {'cache-control': 'public, invalid-directive'}, + headers: {'cache-control': 'public, private'}, ), throwsA( isA().having( (final e) => e.message, 'message', - contains('Invalid directive'), + contains('Cannot be both public and private'), ), ), ); @@ -81,48 +83,44 @@ void main() { ); test( - 'when a Cache-Control header with both public and private is passed then the server responds ' - 'with a bad request including a message that states the directives cannot be both public and private', + 'when a Cache-Control header has both max-age and stale-while-revalidate ' + 'then both parse (they are independent directives, RFC 9111)', () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.cacheControl, - headers: {'cache-control': 'public, private'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Cannot be both public and private'), - ), - ), + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.cacheControl, + headers: { + 'cache-control': 'max-age=3600, stale-while-revalidate=300', + }, ); + + expect(headers.cacheControl?.maxAge, equals(3600)); + expect(headers.cacheControl?.staleWhileRevalidate, equals(300)); }, ); + test('when a Cache-Control header has a negative max-age ' + 'then the directive is ignored (max-age is null)', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.cacheControl, + headers: {'cache-control': 'max-age=-5'}, + ); + + expect(headers.cacheControl?.maxAge, isNull); + }); + test( - 'when a Cache-Control header with both max-age and stale-while-revalidate is passed then the server responds ' - 'with a bad request including a message that states the directives cannot be both max-age and stale-while-revalidate', + 'when a Cache-Control header has an unknown directive with a known prefix ' + 'then it is ignored, not mistaken for max-age', () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.cacheControl, - headers: { - 'cache-control': 'max-age=3600, stale-while-revalidate=300', - }, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains( - 'Cannot have both max-age and stale-while-revalidate directives', - ), - ), - ), + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.cacheControl, + headers: {'cache-control': 'max-age-extended=5'}, ); + + expect(headers.cacheControl?.maxAge, isNull); }, ); @@ -337,7 +335,7 @@ void main() { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'cache-control': 'invalid-directive'}, + headers: {'cache-control': 'public, private'}, ); expect(Headers.cacheControl[headers].valueOrNullIfInvalid, isNull); diff --git a/packages/relic/test/headers/typed/connection_header_test.dart b/packages/relic/test/headers/typed/connection_header_test.dart index 7854b8ce..1aa9716e 100644 --- a/packages/relic/test/headers/typed/connection_header_test.dart +++ b/packages/relic/test/headers/typed/connection_header_test.dart @@ -16,51 +16,45 @@ void main() { tearDown(() => server.close()); - test('when an empty Connection header is passed then the server responds ' - 'with a bad request including a message that states the directives ' - 'cannot be empty', () async { + // Note: rejection of an empty Connection value is covered by a direct unit + // test in packages/relic_core/test/headers/typed/connection_header_test.dart. + // It cannot run as a server round-trip: dart:io's HttpServer drops an empty + // Connection request header (a hop-by-hop, connection-managed field), so it + // never reaches the handler. + + test('when a non-token Connection value is passed then the server responds ' + 'with a bad request', () async { expect( getServerRequestHeaders( server: server, touchHeaders: (final h) => h.connection, - headers: {'connection': ''}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Value cannot be empty'), - ), + headers: {'connection': 'bad directive'}, ), + throwsA(isA()), ); }); - test('when an invalid Connection header is passed then the server responds ' - 'with a bad request including a message that states the value ' - 'is invalid', () async { + test('when an unknown but valid connection-option is passed ' + 'then it parses (open token set)', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.connection, + headers: {'connection': 'TE'}, + ); + expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.connection, - headers: {'connection': 'custom-directive'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid value'), - ), - ), + headers.connection?.directives.map((final d) => d.value), + equals(['te']), ); }); - test('when a Connection header with an invalid value is passed ' + test('when a non-token Connection value is passed ' 'then the server does not respond with a bad request if the headers ' 'is not actually used', () async { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'connection': 'invalid-connection-format'}, + headers: {'connection': 'bad directive'}, ); expect(headers, isNotNull); @@ -137,7 +131,7 @@ void main() { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'connection': ''}, + headers: {'connection': 'bad directive'}, ); expect(Headers.connection[headers].valueOrNullIfInvalid, isNull); diff --git a/packages/relic/test/headers/typed/content_encoding_header_test.dart b/packages/relic/test/headers/typed/content_encoding_header_test.dart index bb3944e8..f59686e3 100644 --- a/packages/relic/test/headers/typed/content_encoding_header_test.dart +++ b/packages/relic/test/headers/typed/content_encoding_header_test.dart @@ -38,35 +38,41 @@ void main() { }, ); + test('when a non-token Content-Encoding value is passed then the server ' + 'responds with a bad request', () async { + expect( + getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.contentEncoding, + headers: {'content-encoding': 'bad encoding'}, + ), + throwsA(isA()), + ); + }); + test( - 'when an invalid Content-Encoding header is passed then the server responds ' - 'with a bad request including a message that states the header value ' - 'is invalid', + 'when an unknown but valid coding is passed then it parses (open registry)', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.contentEncoding, + headers: {'content-encoding': 'custom-encoding'}, + ); + expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.contentEncoding, - headers: {'content-encoding': 'custom-encoding'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid value'), - ), - ), + headers.contentEncoding?.encodings.map((final e) => e.name), + equals(['custom-encoding']), ); }, ); - test('when a Content-Encoding header with an invalid value is passed ' + test('when a non-token Content-Encoding value is passed ' 'then the server does not respond with a bad request if the headers ' 'is not actually used', () async { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'content-encoding': 'custom-encoding'}, + headers: {'content-encoding': 'bad encoding'}, ); expect(headers, isNotNull); diff --git a/packages/relic/test/headers/typed/content_language_header_test.dart b/packages/relic/test/headers/typed/content_language_header_test.dart index 1326f10f..0cd8b209 100644 --- a/packages/relic/test/headers/typed/content_language_header_test.dart +++ b/packages/relic/test/headers/typed/content_language_header_test.dart @@ -46,7 +46,7 @@ void main() { getServerRequestHeaders( server: server, touchHeaders: (final h) => h.contentLanguage, - headers: {'content-language': 'en-123'}, + headers: {'content-language': 'en_US'}, ), throwsA( isA().having( @@ -65,7 +65,7 @@ void main() { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'content-language': 'en-123'}, + headers: {'content-language': 'en_US'}, ); expect(headers, isNotNull); @@ -84,6 +84,20 @@ void main() { }, ); + test('when BCP 47 tags with regions, scripts, and private-use are passed ' + 'then they parse (full grammar, not a narrow regex)', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.contentLanguage, + headers: {'content-language': 'es-419, zh-Hant-TW, x-pig-latin'}, + ); + + expect( + headers.contentLanguage?.languages, + equals(['es-419', 'zh-Hant-TW', 'x-pig-latin']), + ); + }); + group('when multiple Content-Language languages are passed', () { test('then they should parse correctly', () async { final headers = await getServerRequestHeaders( @@ -150,7 +164,7 @@ void main() { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'content-language': 'en-123'}, + headers: {'content-language': 'en_US'}, ); expect(Headers.contentLanguage[headers].valueOrNullIfInvalid, isNull); diff --git a/packages/relic/test/headers/typed/content_security_policy_header_test.dart b/packages/relic/test/headers/typed/content_security_policy_header_test.dart index 8f17795f..450457e2 100644 --- a/packages/relic/test/headers/typed/content_security_policy_header_test.dart +++ b/packages/relic/test/headers/typed/content_security_policy_header_test.dart @@ -69,6 +69,20 @@ void main() { }, ); + test('when a directive name is in mixed case then it is canonicalized to ' + 'lowercase', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.contentSecurityPolicy, + headers: {'content-security-policy': "DEFAULT-SRC 'self'"}, + ); + + expect( + headers.contentSecurityPolicy?.directives.first.name, + equals('default-src'), + ); + }); + test( 'when a Content-Security-Policy header with multiple directives is passed then it should parse correctly', () async { diff --git a/packages/relic/test/headers/typed/cookie_header_test.dart b/packages/relic/test/headers/typed/cookie_header_test.dart index 4a86e7b5..ba29bd5c 100644 --- a/packages/relic/test/headers/typed/cookie_header_test.dart +++ b/packages/relic/test/headers/typed/cookie_header_test.dart @@ -153,6 +153,9 @@ void main() { test( 'when a Cookie header with encoded characters in the value is passed then it should parse correctly', () async { + // Cookie values are opaque octets per RFC 6265; percent encoding is + // an application-level convention and MUST NOT be decoded by the + // server. The raw bytes round-trip through the parser unchanged. final headers = await getServerRequestHeaders( server: server, touchHeaders: (final h) => h.cookie, @@ -165,7 +168,7 @@ void main() { ); expect( headers.cookie?.cookies.map((final c) => c.value).toList(), - equals(['abc 123', '42']), + equals(['abc%20123', '42']), ); }, ); @@ -191,6 +194,32 @@ void main() { }, ); + test( + 'when a Cookie header has two cookies with the same name but different ' + 'values then both are preserved and getCookie returns the first', + () async { + // RFC 6265 5.4 allows a Cookie header to carry duplicate cookie names + // (e.g. a host-only cookie plus a Domain-scoped one); the server cannot + // distinguish them from the header alone. The header must still parse, + // since rejecting it would make an otherwise valid cookie unreadable. + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.cookie, + headers: {'cookie': 'sessionId=abc123; sessionId=xyz789'}, + ); + + expect( + headers.cookie?.cookies.map((final c) => c.name).toList(), + equals(['sessionId', 'sessionId']), + ); + expect( + headers.cookie?.cookies.map((final c) => c.value).toList(), + equals(['abc123', 'xyz789']), + ); + expect(headers.cookie?.getCookie('sessionId')?.value, equals('abc123')); + }, + ); + test( 'when a Cookie header is passed with extra whitespace then it should parse the cookies correctly', () async { diff --git a/packages/relic/test/headers/typed/expect_header_test.dart b/packages/relic/test/headers/typed/expect_header_test.dart index e7a5bf4b..e8e91f4f 100644 --- a/packages/relic/test/headers/typed/expect_header_test.dart +++ b/packages/relic/test/headers/typed/expect_header_test.dart @@ -35,26 +35,16 @@ void main() { ); }); - test( - 'when an invalid Expect header is passed then the server should respond with a bad request ' - 'including a message that states the value is invalid', - () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.expect, - headers: {'expect': 'custom-directive'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid value'), - ), - ), - ); - }, - ); + test('when an Expect header carries an unknown expectation ' + 'then it is preserved so the server can respond with 417', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.expect, + headers: {'expect': 'custom-directive'}, + ); + + expect(headers.expect?.value, equals('custom-directive')); + }); test('when an Expect header with an invalid value is passed ' 'then the server does not respond with a bad request if the headers ' diff --git a/packages/relic/test/headers/typed/from_header_test.dart b/packages/relic/test/headers/typed/from_header_test.dart index f3aecfbf..96373576 100644 --- a/packages/relic/test/headers/typed/from_header_test.dart +++ b/packages/relic/test/headers/typed/from_header_test.dart @@ -16,160 +16,91 @@ void main() { tearDown(() => server.close()); - test('when an empty From header is passed then the server responds ' - 'with a bad request including a message that states the header value ' - 'cannot be empty', () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.from, - headers: {'from': ''}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Value cannot be empty'), + test( + 'when an empty From header is passed ' + 'then the server responds with a bad request stating the value cannot be empty', + () async { + expect( + getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.from, + headers: {'from': ''}, ), - ), - ); - }); - - test('when a From header with an invalid email format is passed ' - 'then the server responds with a bad request including a message that ' - 'states the email format is invalid', () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.from, - headers: {'from': 'invalid-email-format'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid email format'), + throwsA( + isA().having( + (final e) => e.message, + 'message', + contains('Value cannot be empty'), + ), ), - ), + ); + }, + ); + + test('when a From header carries a plain addr-spec ' + 'then it is parsed as the mailbox', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.from, + headers: {'from': 'user@example.com'}, ); + + expect(headers.from?.mailbox, equals('user@example.com')); }); - test('when a From header with an invalid value is passed ' - 'then the server does not respond with a bad request if the headers ' - 'is not actually used', () async { + test('when a From header carries a name-addr form ' + 'then it is preserved verbatim', () async { final headers = await getServerRequestHeaders( server: server, - touchHeaders: (_) {}, - headers: {'from': 'invalid-email-format'}, + touchHeaders: (final h) => h.from, + headers: {'from': 'Webmaster '}, ); - expect(headers, isNotNull); + expect( + headers.from?.mailbox, + equals('Webmaster '), + ); }); test( - 'when a valid From header is passed then it should parse the email correctly', + 'when a From header carries a value that is not a strict email ' + 'then it is accepted as-is (advisory mailbox, not format-validated)', () async { final headers = await getServerRequestHeaders( server: server, touchHeaders: (final h) => h.from, - headers: {'from': 'user@example.com'}, + headers: {'from': 'invalid-email-format'}, ); - expect(headers.from?.emails, equals(['user@example.com'])); + expect(headers.from?.mailbox, equals('invalid-email-format')); }, ); + test('when a From header has surrounding whitespace ' + 'then the mailbox is trimmed', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.from, + headers: {'from': ' user@example.com '}, + ); + + expect(headers.from?.mailbox, equals('user@example.com')); + }); + test( - 'when a From header with extra whitespace is passed then it should parse the email correctly', + 'when an empty From header is passed ' + 'then the server does not respond with a bad request if the headers is not actually used', () async { final headers = await getServerRequestHeaders( server: server, - touchHeaders: (final h) => h.from, - headers: {'from': ' user@example.com '}, + touchHeaders: (_) {}, + headers: {'from': ''}, ); - expect(headers.from?.emails, equals(['user@example.com'])); + expect(headers, isNotNull); }, ); - group('when multiple', () { - test( - 'From headers are passed then they should parse all emails correctly', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.from, - headers: {'from': 'user1@example.com, user2@example.com'}, - ); - - expect( - headers.from?.emails, - equals(['user1@example.com', 'user2@example.com']), - ); - }, - ); - - test( - 'From headers with extra whitespace are passed then they should parse all emails correctly', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.from, - headers: {'from': ' user1@example.com , user2@example.com '}, - ); - - expect( - headers.from?.emails, - equals(['user1@example.com', 'user2@example.com']), - ); - }, - ); - - test( - 'From headers with extra duplicate values are passed then they should ' - 'parse all emails correctly and remove duplicates', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.from, - headers: { - 'from': 'user1@example.com, user2@example.com, user1@example.com', - }, - ); - - expect( - headers.from?.emails, - equals(['user1@example.com', 'user2@example.com']), - ); - }, - ); - - test( - 'From headers with an invalid email format among valid ones are passed ' - 'then the server responds with a bad request including a message that ' - 'states the email format is invalid', - () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.from, - headers: { - 'from': - 'user1@example.com, invalid-email-format, user2@example.com', - }, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid email format'), - ), - ), - ); - }, - ); - }); - test('when no From header is passed then it should return null', () async { final headers = await getServerRequestHeaders( server: server, @@ -190,12 +121,12 @@ void main() { tearDown(() => server.close()); - group('when an invalid From header is passed', () { + group('when an empty From header is passed', () { test('then it should return null', () async { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'from': 'invalid-email-format'}, + headers: {'from': ''}, ); expect(Headers.from[headers].valueOrNullIfInvalid, isNull); diff --git a/packages/relic/test/headers/typed/if_range_header_test.dart b/packages/relic/test/headers/typed/if_range_header_test.dart index bf339e0d..7e51ebd0 100644 --- a/packages/relic/test/headers/typed/if_range_header_test.dart +++ b/packages/relic/test/headers/typed/if_range_header_test.dart @@ -84,20 +84,16 @@ void main() { }, ); - test( - 'when an If-Range header with a weak ETag is passed then it should parse correctly', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.ifRange, - headers: {'if-range': 'W/"123456"'}, - ); + test('when an If-Range header with a weak ETag is passed ' + 'then it is accepted (a consumer treats it as a no-match)', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.ifRange, + headers: {'if-range': 'W/"123456"'}, + ); - expect(headers.ifRange?.etag?.value, equals('123456')); - expect(headers.ifRange?.etag?.isWeak, isTrue); - expect(headers.ifRange?.lastModified, isNull); - }, - ); + expect(headers.ifRange?.etag?.isWeak, isTrue); + }); test( 'when an If-Range header with a valid HTTP date is passed then it should parse correctly', diff --git a/packages/relic/test/headers/typed/permissions_policy_header_test.dart b/packages/relic/test/headers/typed/permissions_policy_header_test.dart index 4cec263c..b4aed0f9 100644 --- a/packages/relic/test/headers/typed/permissions_policy_header_test.dart +++ b/packages/relic/test/headers/typed/permissions_policy_header_test.dart @@ -84,7 +84,7 @@ void main() { expect(policies?[0].name, equals('geolocation')); expect(policies?[0].values, equals(['self'])); expect(policies?[1].name, equals('camera')); - expect(policies?[1].values, equals(['self', '"https://example.com"'])); + expect(policies?[1].values, equals(['self', 'https://example.com'])); }, ); diff --git a/packages/relic/test/headers/typed/proxy_authorization_header_test.dart b/packages/relic/test/headers/typed/proxy_authorization_header_test.dart index 3b318d9a..d03e537f 100644 --- a/packages/relic/test/headers/typed/proxy_authorization_header_test.dart +++ b/packages/relic/test/headers/typed/proxy_authorization_header_test.dart @@ -77,7 +77,7 @@ void main() { isA().having( (final e) => e.message, 'message', - contains('Invalid bearer prefix'), + contains('Invalid Bearer prefix'), ), ), ); diff --git a/packages/relic/test/headers/typed/referrer_policy_header_test.dart b/packages/relic/test/headers/typed/referrer_policy_header_test.dart index f1ea0eca..f4003a1b 100644 --- a/packages/relic/test/headers/typed/referrer_policy_header_test.dart +++ b/packages/relic/test/headers/typed/referrer_policy_header_test.dart @@ -47,17 +47,28 @@ void main() { touchHeaders: (final h) => h.referrerPolicy, headers: {'referrer-policy': 'invalid-value'}, ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid value'), - ), - ), + throwsA(isA()), ); }, ); + test('when a Referrer-Policy header lists a fallback then the last valid ' + 'token wins and unknown tokens are ignored', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.referrerPolicy, + headers: { + 'referrer-policy': + 'no-referrer, made-up, strict-origin-when-cross-origin', + }, + ); + + expect( + headers.referrerPolicy?.directive, + equals('strict-origin-when-cross-origin'), + ); + }); + test('when a Referrer-Policy header with an invalid value is passed ' 'then the server does not respond with a bad request if the headers ' 'is not actually used', () async { diff --git a/packages/relic/test/headers/typed/retry_after_header_test.dart b/packages/relic/test/headers/typed/retry_after_header_test.dart index 66907843..b0b7c058 100644 --- a/packages/relic/test/headers/typed/retry_after_header_test.dart +++ b/packages/relic/test/headers/typed/retry_after_header_test.dart @@ -88,6 +88,17 @@ void main() { ); }); + test('then it should throw an error on a leading-plus value', () async { + expect( + getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.retryAfter, + headers: {'retry-after': '+120'}, + ), + throwsA(isA()), + ); + }); + test('then it should throw an error on a non-integer value', () async { expect( getServerRequestHeaders( diff --git a/packages/relic/test/headers/typed/sec_fetch_dest_header_test.dart b/packages/relic/test/headers/typed/sec_fetch_dest_header_test.dart index a9e61441..fc4391cd 100644 --- a/packages/relic/test/headers/typed/sec_fetch_dest_header_test.dart +++ b/packages/relic/test/headers/typed/sec_fetch_dest_header_test.dart @@ -38,8 +38,8 @@ void main() { ); test( - 'when an invalid Sec-Fetch-Dest header is passed then the server should respond with a bad request ' - 'including a message that states the value is invalid', + 'when an invalid Sec-Fetch-Dest header is passed ' + 'then the server responds with a bad request including a message that states the value is invalid', () async { expect( getServerRequestHeaders( @@ -58,16 +58,18 @@ void main() { }, ); - test('when a Sec-Fetch-Dest header with an invalid value is passed ' - 'then the server does not respond with a bad request if the headers ' - 'is not actually used', () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (_) {}, - headers: {'sec-fetch-dest': 'custom-destination'}, - ); - expect(headers, isNotNull); - }); + test( + 'when a Sec-Fetch-Dest header with an invalid value is passed ' + 'then the server does not respond with a bad request if the headers is not actually used', + () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (_) {}, + headers: {'sec-fetch-dest': 'custom-destination'}, + ); + expect(headers, isNotNull); + }, + ); test( 'when a valid Sec-Fetch-Dest header is passed then it should parse the destination correctly', diff --git a/packages/relic/test/headers/typed/sec_fetch_mode_header_test.dart b/packages/relic/test/headers/typed/sec_fetch_mode_header_test.dart index ef19240f..f21370c1 100644 --- a/packages/relic/test/headers/typed/sec_fetch_mode_header_test.dart +++ b/packages/relic/test/headers/typed/sec_fetch_mode_header_test.dart @@ -38,8 +38,8 @@ void main() { ); test( - 'when an invalid Sec-Fetch-Mode header is passed then the server should respond with a bad request ' - 'including a message that states the value is invalid', + 'when an invalid Sec-Fetch-Mode header is passed ' + 'then the server responds with a bad request including a message that states the value is invalid', () async { expect( getServerRequestHeaders( @@ -58,16 +58,18 @@ void main() { }, ); - test('when a Sec-Fetch-Mode header with an invalid value is passed ' - 'then the server does not respond with a bad request if the headers ' - 'is not actually used', () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (_) {}, - headers: {'sec-fetch-mode': 'custom-mode'}, - ); - expect(headers, isNotNull); - }); + test( + 'when a Sec-Fetch-Mode header with an invalid value is passed ' + 'then the server does not respond with a bad request if the headers is not actually used', + () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (_) {}, + headers: {'sec-fetch-mode': 'custom-mode'}, + ); + expect(headers, isNotNull); + }, + ); test( 'when a valid Sec-Fetch-Mode header is passed then it should parse the mode correctly', diff --git a/packages/relic/test/headers/typed/sec_fetch_site_header_test.dart b/packages/relic/test/headers/typed/sec_fetch_site_header_test.dart index 3f8f706e..8d66ec3a 100644 --- a/packages/relic/test/headers/typed/sec_fetch_site_header_test.dart +++ b/packages/relic/test/headers/typed/sec_fetch_site_header_test.dart @@ -38,8 +38,8 @@ void main() { ); test( - 'when an invalid Sec-Fetch-Site header is passed then the server should respond with a bad request ' - 'including a message that states the value is invalid', + 'when an invalid Sec-Fetch-Site header is passed ' + 'then the server responds with a bad request including a message that states the value is invalid', () async { expect( getServerRequestHeaders( @@ -58,16 +58,18 @@ void main() { }, ); - test('when a Sec-Fetch-Site header with an invalid value is passed ' - 'then the server does not respond with a bad request if the headers ' - 'is not actually used', () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (_) {}, - headers: {'sec-fetch-site': 'custom-site'}, - ); - expect(headers, isNotNull); - }); + test( + 'when a Sec-Fetch-Site header with an invalid value is passed ' + 'then the server does not respond with a bad request if the headers is not actually used', + () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (_) {}, + headers: {'sec-fetch-site': 'custom-site'}, + ); + expect(headers, isNotNull); + }, + ); test( 'when a valid Sec-Fetch-Site header is passed then it should parse the site correctly', diff --git a/packages/relic/test/headers/typed/set_cookie_header_test.dart b/packages/relic/test/headers/typed/set_cookie_header_test.dart index 516bc0cd..9dcf41f3 100644 --- a/packages/relic/test/headers/typed/set_cookie_header_test.dart +++ b/packages/relic/test/headers/typed/set_cookie_header_test.dart @@ -82,48 +82,32 @@ void main() { ); test( - 'when a Set-Cookie header with missing name and value is passed then server ' - 'responds with a bad request including a message that states the Name and Value ' - 'are supplied multiple times', + 'when a Set-Cookie header carries a second name=value pair ' + 'then the second pair is treated as an unknown attribute and dropped', () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.setCookie, - headers: {'set-cookie': 'sessionId=test; userId=42'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Supplied multiple Name and Value attributes'), - ), - ), + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.setCookie, + headers: {'set-cookie': 'sessionId=test; userId=42'}, ); - }, - ); - test( - 'when a Set-Cookie header with invalid format is passed then the server responds ' - 'with a bad request including a message that states the cookie format is invalid', - () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.setCookie, - headers: {'set-cookie': 'sessionId=abc123; invalidCookie'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid cookie format'), - ), - ), - ); + expect(headers.setCookie?.name, equals('sessionId')); + expect(headers.setCookie?.value, equals('test')); }, ); + test('when a Set-Cookie header carries an unknown attribute ' + 'then the attribute is ignored per RFC 6265 5.2', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.setCookie, + headers: {'set-cookie': 'sessionId=abc123; invalidCookie'}, + ); + + expect(headers.setCookie?.name, equals('sessionId')); + expect(headers.setCookie?.value, equals('abc123')); + }); + test( 'when a Set-Cookie header with an invalid name is passed then the server responds ' 'with a bad request including a message that states the cookie name is invalid', @@ -241,20 +225,24 @@ void main() { tearDown(() => server.close()); - group('when parsing an invalid cookie header', () { - test( - 'when an invalid Set-Cookie header is passed then it should return null', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (_) {}, - headers: {'set-cookie': 'sessionId=abc123; invalidCookie'}, - ); - - expect(Headers.setCookie[headers].valueOrNullIfInvalid, isNull); - expect(() => headers.setCookie, throwsInvalidHeader); - }, - ); - }); + group( + 'when parsing a Set-Cookie header with an unrecognized attribute', + () { + test( + 'when the cookie-pair is present alongside the unknown attribute ' + 'then the attribute is ignored and the cookie still parses', + () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (_) {}, + headers: {'set-cookie': 'sessionId=abc123; invalidCookie'}, + ); + + expect(headers.setCookie?.name, equals('sessionId')); + expect(headers.setCookie?.value, equals('abc123')); + }, + ); + }, + ); }); } diff --git a/packages/relic/test/headers/typed/te_header_test.dart b/packages/relic/test/headers/typed/te_header_test.dart index b891125e..196b16fc 100644 --- a/packages/relic/test/headers/typed/te_header_test.dart +++ b/packages/relic/test/headers/typed/te_header_test.dart @@ -35,22 +35,17 @@ void main() { ); }); - test('when a TE header with invalid quality values is passed ' - 'then the server responds with a bad request including a message that ' - 'states the quality value is invalid', () async { + test('when a TE header with a malformed quality value is passed ' + 'then the weight defaults to 1.0 instead of being rejected', () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.te, + headers: {'te': 'trailers;q=abc'}, + ); + expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.te, - headers: {'te': 'trailers;q=abc'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid quality value'), - ), - ), + headers.te?.encodings.map((final e) => e.quality).toList(), + equals([1.0]), ); }); @@ -79,7 +74,7 @@ void main() { final headers = await getServerRequestHeaders( server: server, touchHeaders: (_) {}, - headers: {'te': 'trailers;q=abc'}, + headers: {'te': ';q=1.0'}, ); expect(headers, isNotNull); @@ -209,17 +204,22 @@ void main() { }); }); - group('when TE headers with invalid quality values are passed', () { - test('then it should return null', () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (_) {}, - headers: {'te': 'trailers;q=abc, deflate, gzip'}, - ); + group('when TE headers with a malformed quality value are passed', () { + test( + 'then the header still parses and the weight defaults to 1.0', + () async { + final headers = await getServerRequestHeaders( + server: server, + touchHeaders: (_) {}, + headers: {'te': 'trailers;q=abc, deflate, gzip'}, + ); - expect(Headers.te[headers].valueOrNullIfInvalid, isNull); - expect(() => headers.te, throwsInvalidHeader); - }); + expect( + headers.te?.encodings.map((final e) => e.quality).toList(), + equals([1.0, 1.0, 1.0]), + ); + }, + ); }); }); } diff --git a/packages/relic/test/headers/typed/transfer_encoding_header_test.dart b/packages/relic/test/headers/typed/transfer_encoding_header_test.dart index 94db40e4..6acb53ea 100644 --- a/packages/relic/test/headers/typed/transfer_encoding_header_test.dart +++ b/packages/relic/test/headers/typed/transfer_encoding_header_test.dart @@ -16,130 +16,15 @@ void main() { tearDown(() => server.close()); - test( - 'when an empty Transfer-Encoding header is passed then the server should respond with a bad request ' - 'including a message that states the encodings cannot be empty', - () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.transferEncoding, - headers: {'transfer-encoding': ''}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Value cannot be empty'), - ), - ), - ); - }, - ); - - test( - 'when an invalid Transfer-Encoding header is passed then the server should respond with a bad request ' - 'including a message that states the value is invalid', - () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.transferEncoding, - headers: {'transfer-encoding': 'custom-encoding'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid value'), - ), - ), - ); - }, - ); - - test('when a Transfer-Encoding header with an invalid value is passed ' - 'then the server does not respond with a bad request if the headers ' - 'is not actually used', () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (_) {}, - headers: {'transfer-encoding': 'custom-encoding'}, - ); - - expect(headers, isNotNull); - }); - - test( - 'when a valid Transfer-Encoding header is passed then it should parse the encodings correctly', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.transferEncoding, - headers: {'transfer-encoding': 'gzip, chunked'}, - ); - - expect( - headers.transferEncoding?.encodings.map((final e) => e.name), - equals(['gzip', 'chunked']), - ); - }, - ); - - /// According to the HTTP/1.1 specification (RFC 9112), the 'chunked' transfer - /// encoding must be the final encoding applied to the response body. - test( - 'when a valid Transfer-Encoding header is passed with "chunked" as not the last ' - 'encoding then it should parse the encodings correctly and reorder them sot the ' - 'chunked encoding is the last encoding', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.transferEncoding, - headers: {'transfer-encoding': 'chunked, gzip'}, - ); - - expect( - headers.transferEncoding?.encodings.map((final e) => e.name), - equals(['gzip', 'chunked']), - ); - }, - ); - - test( - 'when a Transfer-Encoding header with duplicate encodings is passed then ' - 'it should parse the encodings correctly and remove duplicates', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.transferEncoding, - headers: {'transfer-encoding': 'gzip, chunked, chunked'}, - ); - - expect( - headers.transferEncoding?.encodings.map((final e) => e.name), - equals(['gzip', 'chunked']), - ); - }, - ); - - test( - 'when a Transfer-Encoding header contains "chunked" then isChunked should be true', - () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.transferEncoding, - headers: {'transfer-encoding': 'gzip, chunked'}, - ); - - expect( - headers.transferEncoding?.encodings.any( - (final e) => e.name == TransferEncoding.chunked.name, - ), - isTrue, - ); - }, - ); + // Note: validation and parse/encode behavior (empty value, unknown coding, + // chunked-not-last, multi-coding ordering, duplicate removal, isChunked) is + // covered by direct unit tests in + // packages/relic_core/test/headers/typed/transfer_encoding_header_test.dart. + // These cannot run as server round-trips: dart:io owns Transfer-Encoding + // framing. On a bodyless GET it waits for a chunked body the client never + // sends, and on Dart 3.13+ HttpServer rejects an empty or unknown coding at + // the protocol layer (closing the connection before the handler runs). See + // the dart-io-transfer-encoding-close-hang reproduction. test( 'when no Transfer-Encoding header is passed then it should return null', @@ -154,27 +39,4 @@ void main() { }, ); }); - - group('Given a Transfer-Encoding header without validation', () { - late RelicServer server; - - setUp(() async { - server = await createServer(); - }); - - tearDown(() => server.close()); - - group('when an empty Transfer-Encoding header is passed', () { - test('then it should return null', () async { - final headers = await getServerRequestHeaders( - server: server, - touchHeaders: (_) {}, - headers: {'transfer-encoding': ''}, - ); - - expect(Headers.transferEncoding[headers].valueOrNullIfInvalid, isNull); - expect(() => headers.transferEncoding, throwsInvalidHeader); - }); - }); - }); } diff --git a/packages/relic/test/headers/typed/upgrade_header_test.dart b/packages/relic/test/headers/typed/upgrade_header_test.dart index a59e8464..f4c1b804 100644 --- a/packages/relic/test/headers/typed/upgrade_header_test.dart +++ b/packages/relic/test/headers/typed/upgrade_header_test.dart @@ -37,27 +37,17 @@ void main() { }, ); - test( - 'when an Upgrade header with an invalid protocol version is passed then ' - 'the server should respond with a bad request including a message that ' - 'states the version is invalid', - () async { - expect( - getServerRequestHeaders( - server: server, - touchHeaders: (final h) => h.upgrade, - headers: {'upgrade': 'InvalidProtocol/abc'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid version'), - ), - ), - ); - }, - ); + test('when an Upgrade header has a non-token protocol version then ' + 'the server should respond with a bad request', () async { + expect( + getServerRequestHeaders( + server: server, + touchHeaders: (final h) => h.upgrade, + headers: {'upgrade': 'Proto/has space'}, + ), + throwsA(isA()), + ); + }); test('when a Upgrade header with an invalid protocol is passed ' 'then the server does not respond with a bad request if the headers ' @@ -86,22 +76,16 @@ void main() { group('when multiple Upgrade protocols are passed', () { test( - 'with invalid protocols versions then the server should respond with a ' - 'bad request including a message that states the version is invalid', + 'with non-token protocol versions then the server should respond with a ' + 'bad request', () async { expect( getServerRequestHeaders( server: server, touchHeaders: (final h) => h.upgrade, - headers: {'upgrade': 'HTTP/2.0, HTTP/abc'}, - ), - throwsA( - isA().having( - (final e) => e.message, - 'message', - contains('Invalid version'), - ), + headers: {'upgrade': 'HTTP/2.0, HTTP/has space'}, ), + throwsA(isA()), ); }, ); @@ -116,7 +100,7 @@ void main() { final protocols = headers.upgrade?.protocols; expect(protocols?.length, equals(2)); expect(protocols?[0].protocol, equals('HTTP')); - expect(protocols?[0].version, equals(2)); + expect(protocols?[0].version, equals('2.0')); expect(protocols?[1].protocol, equals('WebSocket')); expect(protocols?[1].version, isNull); }); diff --git a/packages/relic/test/headers/typed/vary_header_test.dart b/packages/relic/test/headers/typed/vary_header_test.dart index 87d0d693..fbaef1e8 100644 --- a/packages/relic/test/headers/typed/vary_header_test.dart +++ b/packages/relic/test/headers/typed/vary_header_test.dart @@ -80,7 +80,7 @@ void main() { headers: {'vary': 'Accept-Encoding, User-Agent'}, ); - expect(headers.vary?.fields, equals(['Accept-Encoding', 'User-Agent'])); + expect(headers.vary?.fields, equals(['accept-encoding', 'user-agent'])); }, ); @@ -93,7 +93,21 @@ void main() { headers: {'vary': ' Accept-Encoding , User-Agent '}, ); - expect(headers.vary?.fields, equals(['Accept-Encoding', 'User-Agent'])); + expect(headers.vary?.fields, equals(['accept-encoding', 'user-agent'])); + }, + ); + + test( + 'when two Vary headers differ only in field-name case then they are equal', + () { + expect( + VaryHeader.parse(['Accept-Encoding']), + equals(VaryHeader.parse(['accept-encoding'])), + ); + expect( + VaryHeader.parse(['Accept-Encoding']).hashCode, + equals(VaryHeader.parse(['accept-encoding']).hashCode), + ); }, ); diff --git a/packages/relic/test/message/apply_headers_test.dart b/packages/relic/test/message/apply_headers_test.dart index cff5523f..e5f1dcf3 100644 --- a/packages/relic/test/message/apply_headers_test.dart +++ b/packages/relic/test/message/apply_headers_test.dart @@ -185,6 +185,27 @@ void main() { ); }); + test('with a non-chunked transfer encoding and a streaming body ' + 'when applying headers to http response ' + 'then "chunked" is appended without throwing', () async { + final HttpResponseMock response = HttpResponseMock(); + response.applyHeaders( + Headers.build( + (final mh) => mh.transferEncoding = TransferEncodingHeader.encodings([ + TransferEncoding.gzip, + ]), + ), + Body.fromDataStream( + Stream.fromIterable([Uint8List.fromList('hello'.codeUnits)]), + ), + ); + + expect( + response.headers['transfer-encoding'], + equals([TransferEncoding.gzip.name, TransferEncoding.chunked.name]), + ); + }); + group('with status code', () { test('100 (continue) ' 'when applying headers to http response ' diff --git a/packages/relic_core/lib/relic_core.dart b/packages/relic_core/lib/relic_core.dart index f4f54dbf..f9ed8fe3 100644 --- a/packages/relic_core/lib/relic_core.dart +++ b/packages/relic_core/lib/relic_core.dart @@ -17,6 +17,15 @@ export 'src/headers/exception/header_exception.dart' export 'src/headers/header_accessor.dart'; export 'src/headers/headers.dart'; export 'src/headers/standard_headers_extensions.dart'; +export 'src/headers/typed/primitives/delta_seconds.dart' show DeltaSeconds; +export 'src/headers/typed/primitives/etag_value.dart' show ETagValue; +export 'src/headers/typed/primitives/header_scanner.dart' show HeaderScanner; +export 'src/headers/typed/primitives/host.dart' show Host; +export 'src/headers/typed/primitives/language_tag.dart' show LanguageTag; +export 'src/headers/typed/primitives/origin.dart' + show OpaqueOrigin, Origin, TupleOrigin; +export 'src/headers/typed/primitives/parameter_value.dart' show ParameterValue; +export 'src/headers/typed/primitives/token.dart' show Token, TokenValue; export 'src/headers/typed/typed_headers.dart'; export 'src/ip_address/endianness.dart'; export 'src/ip_address/ip_address.dart'; diff --git a/packages/relic_core/lib/src/headers/typed/headers/accept_encoding_header.dart b/packages/relic_core/lib/src/headers/typed/headers/accept_encoding_header.dart index a46b48ee..6d138693 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/accept_encoding_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/accept_encoding_header.dart @@ -1,4 +1,5 @@ import '../../../../relic_core.dart'; +import 'util/qvalue.dart'; import 'wildcard_list_header.dart'; /// A class representing the HTTP Accept-Encoding header. @@ -53,28 +54,41 @@ class EncodingQuality { : quality = quality ?? 1.0; /// Parses a string value and returns an [EncodingQuality] instance. + /// + /// The weight is recognized as the parameter named `q` (RFC 9110 12.4.2), + /// case-insensitive, tolerating OWS around the surrounding `;` and `=`. factory EncodingQuality.parse(final String value) { - final encodingParts = value.split(';q='); - final encoding = encodingParts[0].trim().toLowerCase(); + final parts = value.split(';'); + final encoding = parts[0].trim().toLowerCase(); if (encoding.isEmpty) { throw const FormatException('Invalid encoding'); } double? quality; - if (encodingParts.length > 1) { - final qualityValue = double.tryParse(encodingParts[1].trim()); - if (qualityValue == null || qualityValue < 0 || qualityValue > 1) { - throw const FormatException('Invalid quality value'); + for (var i = 1; i < parts.length; i++) { + final eq = parts[i].indexOf('='); + if (eq < 0) continue; + final name = parts[i].substring(0, eq).trim(); + if (name.toLowerCase() != 'q') continue; + final parsed = double.tryParse(parts[i].substring(eq + 1).trim()); + // A malformed or out-of-range weight is treated as absent (defaulting to + // 1.0) rather than rejecting the whole header: the client did list this + // entry, so it is acceptable; only the unparseable preference is dropped + // (RFC 9110 12.4.2; robustness on received headers). + if (parsed != null && parsed >= 0 && parsed <= 1) { + quality = parsed; } - quality = qualityValue; + break; } return EncodingQuality(encoding, quality); } - /// Encodes this [EncodingQuality] into a string representation suitable for HTTP headers. + /// Encodes this [EncodingQuality] into a string representation suitable for + /// HTTP headers. The q-value is rendered with at most 3 fractional digits + /// per RFC 9110 12.4.2. String encode() { - return quality == 1.0 ? encoding : '$encoding;q=$quality'; + return quality == 1.0 ? encoding : '$encoding;q=${formatQValue(quality!)}'; } @override diff --git a/packages/relic_core/lib/src/headers/typed/headers/accept_header.dart b/packages/relic_core/lib/src/headers/typed/headers/accept_header.dart index 42921e83..1e5c99cf 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/accept_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/accept_header.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import '../../../../relic_core.dart'; import '../../extension/string_list_extensions.dart'; +import 'util/qvalue.dart'; /// A class representing the HTTP Accept header. /// @@ -70,8 +71,11 @@ class MediaRange { /// Parses a media range string and returns a [MediaRange] instance. /// - /// This method processes the media range string, extracting the type, - /// subtype, quality, and parameters. + /// Parameters are split on `;` (RFC 9110 12.5.1 `parameters`), and the + /// quality value `q=...` is matched by parameter name (case-insensitive, + /// surrounded by OWS) per RFC 9110 12.4.2 - not by a literal `q=` + /// substring, which silently misparses any input with whitespace around + /// the semicolon. factory MediaRange.parse(final String value) { final parts = value.splitTrimAndFilterUnique(separator: ';').toList(); final typeSubtype = parts.first.split('/'); @@ -83,25 +87,30 @@ class MediaRange { final subtype = typeSubtype[1].trim(); double? quality; - if (parts.length > 1) { - final qualityParts = parts[1] - .splitTrimAndFilterUnique(separator: 'q=') - .firstOrNull; - if (qualityParts != null) { - final value = double.tryParse(qualityParts); - if (value == null || value < 0 || value > 1) { - throw const FormatException('Invalid quality value'); - } - quality = value; + for (var i = 1; i < parts.length; i++) { + final eq = parts[i].indexOf('='); + if (eq < 0) continue; + final name = parts[i].substring(0, eq).trim(); + if (name.toLowerCase() != 'q') continue; + final parsed = double.tryParse(parts[i].substring(eq + 1).trim()); + // A malformed or out-of-range weight is treated as absent (defaulting to + // 1.0) rather than rejecting the whole header: the client did list this + // entry, so it is acceptable; only the unparseable preference is dropped + // (RFC 9110 12.4.2; robustness on received headers). + if (parsed != null && parsed >= 0 && parsed <= 1) { + quality = parsed; } + break; } return MediaRange(type, subtype, quality: quality); } - /// Converts the [MediaRange] instance into a string representation suitable for HTTP headers. + /// Converts the [MediaRange] instance into a string representation suitable + /// for HTTP headers. The q-value is rendered with at most 3 fractional + /// digits per RFC 9110 12.4.2 (`qvalue = ( "0" [ "." 0*3DIGIT ] ) / ...`). String _encode() { - final qualityStr = quality == 1.0 ? '' : ';q=$quality'; + final qualityStr = quality == 1.0 ? '' : ';q=${formatQValue(quality)}'; return '$type/$subtype$qualityStr'; } diff --git a/packages/relic_core/lib/src/headers/typed/headers/accept_language_header.dart b/packages/relic_core/lib/src/headers/typed/headers/accept_language_header.dart index 71598bcb..77ca9e42 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/accept_language_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/accept_language_header.dart @@ -1,4 +1,5 @@ import '../../../../relic_core.dart'; +import 'util/qvalue.dart'; import 'wildcard_list_header.dart'; /// A class representing the HTTP Accept-Language header. @@ -53,28 +54,41 @@ class LanguageQuality { : quality = quality ?? 1.0; /// Parses a string value and returns a [LanguageQuality] instance. + /// + /// The weight is recognized as the parameter named `q` (RFC 9110 12.4.2), + /// case-insensitive, tolerating OWS around the surrounding `;` and `=`. factory LanguageQuality.parse(final String value) { - final languageParts = value.split(';q='); - final language = languageParts[0].trim().toLowerCase(); + final parts = value.split(';'); + final language = parts[0].trim().toLowerCase(); if (language.isEmpty) { throw const FormatException('Invalid language'); } double? quality; - if (languageParts.length > 1) { - final qualityValue = double.tryParse(languageParts[1].trim()); - if (qualityValue == null || qualityValue < 0 || qualityValue > 1) { - throw const FormatException('Invalid quality value'); + for (var i = 1; i < parts.length; i++) { + final eq = parts[i].indexOf('='); + if (eq < 0) continue; + final name = parts[i].substring(0, eq).trim(); + if (name.toLowerCase() != 'q') continue; + final parsed = double.tryParse(parts[i].substring(eq + 1).trim()); + // A malformed or out-of-range weight is treated as absent (defaulting to + // 1.0) rather than rejecting the whole header: the client did list this + // entry, so it is acceptable; only the unparseable preference is dropped + // (RFC 9110 12.4.2; robustness on received headers). + if (parsed != null && parsed >= 0 && parsed <= 1) { + quality = parsed; } - quality = qualityValue; + break; } return LanguageQuality(language, quality); } - /// Encodes this [LanguageQuality] into a string representation suitable for HTTP headers. + /// Encodes this [LanguageQuality] into a string representation suitable for + /// HTTP headers. The q-value is rendered with at most 3 fractional digits + /// per RFC 9110 12.4.2. String encode() { - return quality == 1.0 ? language : '$language;q=$quality'; + return quality == 1.0 ? language : '$language;q=${formatQValue(quality!)}'; } @override diff --git a/packages/relic_core/lib/src/headers/typed/headers/accept_ranges_header.dart b/packages/relic_core/lib/src/headers/typed/headers/accept_ranges_header.dart index fde94f9e..a97abe22 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/accept_ranges_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/accept_ranges_header.dart @@ -1,60 +1,71 @@ +import 'package:collection/collection.dart'; + import '../../../../relic_core.dart'; /// A class representing the HTTP Accept-Ranges header. /// -/// This class manages the range units that the server supports. +/// Per RFC 9110 14.3, `Accept-Ranges = acceptable-ranges` where +/// `acceptable-ranges = 1#range-unit` (a comma-separated list). The legacy +/// value `none` indicates the server does not support range requests. final class AcceptRangesHeader { static const codec = HeaderCodec.single(AcceptRangesHeader.parse, __encode); static List __encode(final AcceptRangesHeader value) => [ value._encode(), ]; - /// The range unit supported by the server, or `none` if not supported. - final String rangeUnit; + /// The range units supported by the server (canonical lowercase tokens). + final List rangeUnits; - /// Constructs an [AcceptRangesHeader] instance with the specified range unit. - const AcceptRangesHeader._({required this.rangeUnit}); + /// Constructs an [AcceptRangesHeader] with the given range units. + const AcceptRangesHeader._(this.rangeUnits); - /// Constructs an [AcceptRangesHeader] instance with the range unit set to 'none'. - factory AcceptRangesHeader.none() => - const AcceptRangesHeader._(rangeUnit: 'none'); + /// Constructs an [AcceptRangesHeader] signalling no range support (`none`). + factory AcceptRangesHeader.none() => const AcceptRangesHeader._(['none']); - /// Constructs an [AcceptRangesHeader] instance with the range unit set to 'bytes'. - factory AcceptRangesHeader.bytes() => - const AcceptRangesHeader._(rangeUnit: 'bytes'); + /// Constructs an [AcceptRangesHeader] supporting byte ranges (`bytes`). + factory AcceptRangesHeader.bytes() => const AcceptRangesHeader._(['bytes']); - /// Parses the Accept-Ranges header value and returns an [AcceptRangesHeader] instance. - /// - /// This method processes the header value, extracting the range unit. + /// Parses the Accept-Ranges header value as `1#range-unit`. factory AcceptRangesHeader.parse(final String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { + final units = value + .split(',') + .map((final u) => u.trim()) + .where((final u) => u.isNotEmpty) + .map((final u) => Token.validate(u).toLowerCase()) + .toList(); + if (units.isEmpty) { throw const FormatException('Value cannot be empty'); } - - return AcceptRangesHeader._(rangeUnit: trimmed); + // `none` is the no-range-support sentinel; combining it with real range + // units (e.g. `bytes, none`) is contradictory and is rejected so isBytes / + // isNone cannot both hold. + if (units.contains('none') && units.length > 1) { + throw const FormatException( + 'Accept-Ranges "none" must not be combined with other range units', + ); + } + return AcceptRangesHeader._(List.unmodifiable(units)); } - /// Returns `true` if the range unit is 'bytes', otherwise `false`. - bool get isBytes => rangeUnit == 'bytes'; - - /// Returns `true` if the range unit is 'none' or `null`, otherwise `false`. - bool get isNone => rangeUnit == 'none'; + /// Returns `true` if `bytes` is among the supported range units. + bool get isBytes => rangeUnits.contains('bytes'); - /// Converts the [AcceptRangesHeader] instance into a string representation suitable for HTTP headers. + /// Returns `true` if the header is exactly the `none` no-support signal. + bool get isNone => rangeUnits.length == 1 && rangeUnits.first == 'none'; - String _encode() => rangeUnit; + String _encode() => rangeUnits.join(', '); @override bool operator ==(final Object other) => identical(this, other) || - other is AcceptRangesHeader && rangeUnit == other.rangeUnit; + other is AcceptRangesHeader && + const ListEquality().equals(rangeUnits, other.rangeUnits); @override - int get hashCode => rangeUnit.hashCode; + int get hashCode => const ListEquality().hash(rangeUnits); @override String toString() { - return 'AcceptRangesHeader(rangeUnit: $rangeUnit)'; + return 'AcceptRangesHeader(rangeUnits: $rangeUnits)'; } } diff --git a/packages/relic_core/lib/src/headers/typed/headers/access_control_allow_headers_header.dart b/packages/relic_core/lib/src/headers/typed/headers/access_control_allow_headers_header.dart index 968ba42f..4759d8d4 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/access_control_allow_headers_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/access_control_allow_headers_header.dart @@ -9,8 +9,11 @@ final class AccessControlAllowHeadersHeader extends WildcardListHeader { static const codec = HeaderCodec(_parse, _encode); /// Constructs an instance allowing specific headers to be allowed. + /// + /// Header names are case-insensitive (RFC 9110 5.1), so they are + /// canonicalized to lowercase for case-insensitive equality and membership. AccessControlAllowHeadersHeader.headers(final Iterable headers) - : super(List.from(headers)); + : super(List.from(headers.map((final h) => h.toLowerCase()))); /// Constructs an instance allowing all headers to be allowed (`*`). const AccessControlAllowHeadersHeader.wildcard() : super.wildcard(); @@ -27,7 +30,7 @@ final class AccessControlAllowHeadersHeader extends WildcardListHeader { static AccessControlAllowHeadersHeader _parse(final Iterable values) { final parsed = WildcardListHeader.parse( values, - (final String value) => value, + (final String value) => value.toLowerCase(), ); if (parsed.isWildcard) { diff --git a/packages/relic_core/lib/src/headers/typed/headers/access_control_allow_origin_header.dart b/packages/relic_core/lib/src/headers/typed/headers/access_control_allow_origin_header.dart index c5217d03..8e05810e 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/access_control_allow_origin_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/access_control_allow_origin_header.dart @@ -1,9 +1,16 @@ import '../../../../relic_core.dart'; -/// A class representing the HTTP Access-Control-Allow-Origin header. +/// The HTTP `Access-Control-Allow-Origin` response header. /// -/// This header specifies which origins are allowed to access the resource. -/// It can be a specific origin or a wildcard (`*`) to allow any origin. +/// Per the WHATWG Fetch Standard, the value is exactly one of: +/// +/// * `*` (the wildcard - any origin is allowed; not usable with credentials). +/// * `null` (the opaque-origin sentinel - e.g. for sandboxed `iframe`s). +/// * A serialized origin (`scheme://host[:port]`). +/// +/// A list of origins is *not* legal here, regardless of how some servers +/// behave in the wild. Trailing slashes, paths, queries, and fragments are +/// also rejected. final class AccessControlAllowOriginHeader { static const codec = HeaderCodec.single( AccessControlAllowOriginHeader.parse, @@ -13,58 +20,50 @@ final class AccessControlAllowOriginHeader { value._encode(), ]; - /// The allowed origin URI, if specified. - final Uri? origin; + /// The allowed origin, or `null` when this header is the wildcard `*`. + /// + /// A value of [OpaqueOrigin.instance] represents the `null` wire token (an + /// opaque origin) - which is distinct from this field itself being `null` + /// (the wildcard). + final Origin? origin; - /// Whether any origin is allowed (`*`). - final bool isWildcard; + /// Whether this header value is the wildcard `*` (any origin). + bool get isWildcard => origin == null; - /// Constructs an instance allowing a specific origin. - const AccessControlAllowOriginHeader.origin({required this.origin}) - : isWildcard = false; + /// Constructs an instance allowing the given [origin]. + const AccessControlAllowOriginHeader.origin({required Origin this.origin}); /// Constructs an instance allowing any origin (`*`). - const AccessControlAllowOriginHeader.wildcard() - : origin = null, - isWildcard = true; + const AccessControlAllowOriginHeader.wildcard() : origin = null; - /// Parses the Access-Control-Allow-Origin header value and - /// returns an [AccessControlAllowOriginHeader] instance. + /// Parses the `Access-Control-Allow-Origin` header value and returns an + /// [AccessControlAllowOriginHeader] instance. /// - /// This method checks if the value is a wildcard or a specific origin. + /// Throws [FormatException] if [value] is empty or is not a valid origin + /// per [Origin.parse]. factory AccessControlAllowOriginHeader.parse(final String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { throw const FormatException('Value cannot be empty'); } - if (trimmed == '*') { return const AccessControlAllowOriginHeader.wildcard(); } - - try { - return AccessControlAllowOriginHeader.origin(origin: Uri.parse(trimmed)); - } catch (_) { - throw const FormatException('Invalid URI format'); - } + return AccessControlAllowOriginHeader.origin(origin: Origin.parse(trimmed)); } - /// Converts the [AccessControlAllowOriginHeader] instance into a string - /// representation suitable for HTTP headers. - - String _encode() => isWildcard ? '*' : origin.toString(); + String _encode() => origin?.encode() ?? '*'; @override bool operator ==(final Object other) => identical(this, other) || - other is AccessControlAllowOriginHeader && - isWildcard == other.isWildcard && - origin == other.origin; + other is AccessControlAllowOriginHeader && origin == other.origin; @override - int get hashCode => Object.hash(isWildcard, origin); + // The wildcard (`origin == null`) gets a distinct sentinel hash so it does + // not share bucket 0 with an opaque-origin value. + int get hashCode => origin?.hashCode ?? 0x2A; // '*' @override - String toString() => - 'AccessControlAllowOriginHeader(origin: $origin, isWildcard: $isWildcard)'; + String toString() => 'AccessControlAllowOriginHeader($origin)'; } diff --git a/packages/relic_core/lib/src/headers/typed/headers/access_control_expose_headers_header.dart b/packages/relic_core/lib/src/headers/typed/headers/access_control_expose_headers_header.dart index 524831f6..1680811d 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/access_control_expose_headers_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/access_control_expose_headers_header.dart @@ -10,8 +10,11 @@ final class AccessControlExposeHeadersHeader static const codec = HeaderCodec(_parse, _encode); /// Constructs an instance allowing specific headers to be exposed. + /// + /// Header names are case-insensitive (RFC 9110 5.1), so they are + /// canonicalized to lowercase for case-insensitive equality and membership. AccessControlExposeHeadersHeader.headers(final Iterable headers) - : super(List.from(headers)); + : super(List.from(headers.map((final h) => h.toLowerCase()))); /// Constructs an instance allowing all headers to be exposed (`*`). const AccessControlExposeHeadersHeader.wildcard() : super.wildcard(); @@ -32,7 +35,7 @@ final class AccessControlExposeHeadersHeader ) { final parsed = WildcardListHeader.parse( values, - (final String value) => value, + (final String value) => value.toLowerCase(), ); if (parsed.isWildcard) { diff --git a/packages/relic_core/lib/src/headers/typed/headers/authorization_header.dart b/packages/relic_core/lib/src/headers/typed/headers/authorization_header.dart index 4209ed3e..837fd654 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/authorization_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/authorization_header.dart @@ -33,16 +33,31 @@ abstract class AuthorizationHeader { throw const FormatException('Value cannot be empty'); } - if (value.startsWith(BearerAuthorizationHeader.prefix.trim())) { - return BearerAuthorizationHeader.parse(value); - } else if (value.startsWith(BasicAuthorizationHeader.prefix.trim())) { - return BasicAuthorizationHeader.parse(value); - } else if (value.startsWith(DigestAuthorizationHeader.prefix.trim())) { - return DigestAuthorizationHeader.parse(value); + // The auth-scheme is case-insensitive (RFC 9110 11.1). + final sp = value.indexOf(' '); + final scheme = (sp < 0 ? value : value.substring(0, sp)).toLowerCase(); + switch (scheme) { + case 'bearer': + return BearerAuthorizationHeader.parse(value); + case 'basic': + return BasicAuthorizationHeader.parse(value); + case 'digest': + return DigestAuthorizationHeader.parse(value); + default: + throw const FormatException('Invalid header format'); } + } +} - throw const FormatException('Invalid header format'); +/// Strips a case-insensitive auth-scheme [prefix] (e.g. `"Bearer "`) from +/// [value], returning the trimmed remainder. Throws [FormatException] if +/// [value] does not start with [prefix]. +String _stripScheme(final String value, final String prefix) { + if (value.length < prefix.length || + value.substring(0, prefix.length).toLowerCase() != prefix.toLowerCase()) { + throw FormatException('Invalid ${prefix.trim()} prefix', value); } + return value.substring(prefix.length).trim(); } /// Represents a Bearer token for HTTP Authorization. @@ -74,11 +89,7 @@ final class BearerAuthorizationHeader extends AuthorizationHeader { throw const FormatException('Bearer token cannot be empty.'); } - if (!value.startsWith(prefix)) { - throw const FormatException('Invalid bearer prefix'); - } - - final token = value.substring(prefix.length).trim(); + final token = _stripScheme(value, prefix); if (token.isEmpty) { throw const FormatException('Bearer token cannot be empty.'); } @@ -148,9 +159,8 @@ final class BasicAuthorizationHeader extends AuthorizationHeader { if (username.contains(':')) { throw const FormatException('Username cannot contain ":"'); } - if (password.isEmpty) { - throw const FormatException('Password cannot be empty'); - } + // RFC 7617 permits an empty password (e.g. the `apikey:` pattern), so it + // is not rejected here. } /// Factory constructor to create a [BasicAuthorizationHeader] from a token string. @@ -163,11 +173,7 @@ final class BasicAuthorizationHeader extends AuthorizationHeader { throw const FormatException('Basic token cannot be empty.'); } - if (!value.startsWith(prefix)) { - throw const FormatException('Invalid basic prefix'); - } - - final base64Part = value.substring(prefix.length).trim(); + final base64Part = _stripScheme(value, prefix); try { final decoded = utf8.decode(base64Decode(base64Part)); @@ -289,6 +295,16 @@ final class DigestAuthorizationHeader extends AuthorizationHeader { if (response.isEmpty) { throw const FormatException('Response cannot be empty'); } + // algorithm/qop are serialized as bare tokens (RFC 7616 3.4), so they must + // be valid tokens to avoid emitting a malformed or injectable header. + if (algorithm != null) Token.validate(algorithm!); + if (qop != null) Token.validate(qop!); + // nc is `nc-value = 8LHEX` (RFC 7616 3.4), not an arbitrary token. + if (nc != null && !RegExp(r'^[0-9A-Fa-f]{8}$').hasMatch(nc!)) { + throw const FormatException( + 'Digest nc must be exactly 8 hexadecimal digits', + ); + } } /// Parses a Digest authorization header value and returns a [DigestAuthorizationHeader] instance. @@ -300,10 +316,20 @@ final class DigestAuthorizationHeader extends AuthorizationHeader { throw const FormatException('Digest token cannot be empty.'); } + // Each auth-param is `token = ( token / quoted-string )` (RFC 7616 3.4): + // quoted-string values are DQUOTE-wrapped (group 2, with quoted-pair + // escapes), token values are bare (group 3). Accepting both is required + // because conformant peers send algorithm/qop/nc/stale unquoted. final Map params = {}; - final regex = RegExp(r'(\w+)="([^"]*)"'); + final regex = RegExp(r'(\w+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^",\s]+))'); for (final match in regex.allMatches(value)) { - params[match.group(1)!] = match.group(2)!; + final quoted = match.group(2); + // A bare (unquoted) value must be a valid token; reject e.g. + // `algorithm=MD5;evil`, which would otherwise be stored and re-emitted + // verbatim. + params[match.group(1)!] = quoted != null + ? _unescapeQuoted(quoted) + : Token.validate(match.group(3)!); } if (params.isEmpty) { @@ -349,21 +375,29 @@ final class DigestAuthorizationHeader extends AuthorizationHeader { } /// Returns the full authorization string for Digest authentication. + /// + /// Per RFC 7616 section 3.4, the `algorithm`, `qop`, `nc`, and `stale` + /// parameters carry token values and MUST NOT be quoted on the wire; only + /// `username`, `realm`, `nonce`, `uri`, `response`, `cnonce`, and `opaque` + /// take quoted-string form. Strict server implementations (e.g. Apache + /// `mod_auth_digest`) reject requests that quote the token-form parameters. @override String get headerValue { - return [ - 'Digest', - '$_username="$username"', - '$_realm="$realm"', - '$_nonce="$nonce"', - '$_uri="$uri"', - '$_response="$response"', - if (algorithm != null) '$_algorithm="$algorithm"', - if (qop != null) '$_qop="$qop"', - if (nc != null) '$_nc="$nc"', - if (cnonce != null) '$_cnonce="$cnonce"', - if (opaque != null) '$_opaque="$opaque"', - ].join(', '); + final params = [ + '$_username=${_quoteString(username)}', + '$_realm=${_quoteString(realm)}', + '$_nonce=${_quoteString(nonce)}', + '$_uri=${_quoteString(uri)}', + '$_response=${_quoteString(response)}', + if (algorithm != null) '$_algorithm=$algorithm', + if (qop != null) '$_qop=$qop', + if (nc != null) '$_nc=$nc', + if (cnonce != null) '$_cnonce=${_quoteString(cnonce!)}', + if (opaque != null) '$_opaque=${_quoteString(opaque!)}', + ]; + // RFC 7235 2.1: a single SP separates the auth-scheme from the first + // auth-param; auth-params are then comma-separated. + return 'Digest ${params.join(', ')}'; } @override @@ -432,3 +466,25 @@ final class DigestAuthorizationHeader extends AuthorizationHeader { ')'; } } + +/// Wraps [s] in DQUOTEs, escaping interior `"` and `\` as `quoted-pair` +/// (RFC 9110 5.6.4). Without this a value containing a quote would terminate +/// the quoted-string early and corrupt the parsed credentials. +/// +/// A control character (CR/LF in particular) is rejected rather than emitted +/// verbatim, so a caller-controlled Digest field cannot split the header. +String _quoteString(final String s) { + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c <= 0x1F || c == 0x7F) { + throw const FormatException( + 'Digest quoted-string value must not contain control characters', + ); + } + } + return '"${s.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"'; +} + +/// Decodes `quoted-pair` escapes in a quoted-string body: `\x` becomes `x`. +String _unescapeQuoted(final String s) => + s.replaceAllMapped(RegExp(r'\\(.)'), (final m) => m.group(1)!); diff --git a/packages/relic_core/lib/src/headers/typed/headers/cache_control_header.dart b/packages/relic_core/lib/src/headers/typed/headers/cache_control_header.dart index 0604e28b..3502cbed 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/cache_control_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/cache_control_header.dart @@ -118,11 +118,20 @@ final class CacheControlHeader { if (publicCache == true && privateCache == true) { throw const FormatException('Must be either public or private'); } - if (maxAge != null && staleWhileRevalidate != null) { - throw const FormatException( - 'Cannot have both max-age and stale-while-revalidate directives', - ); - } + } + + static final Set _validDirectiveSet = _validDirectives.toSet(); + + /// Parses a `delta-seconds` directive value, returning `null` for an absent, + /// non-numeric, or negative value. A `delta-seconds` is `1*DIGIT` (RFC 9111 + /// 1.2.2), so anything else is malformed; the directive is ignored rather + /// than failing the whole header (RFC 9111 5.2 has recipients ignore + /// directives they cannot use). + static int? _delta(final String? raw) { + if (raw == null || raw.isEmpty) return null; + final n = int.tryParse(raw); + if (n == null || n < 0) return null; + return n; } /// Parses the Cache-Control header value and returns a [CacheControlHeader] instance. @@ -136,75 +145,80 @@ final class CacheControlHeader { throw const FormatException('Directives cannot be empty'); } - // Check if at least one directive is valid - final foundOneDirective = directives.any( - (final directive) => _validDirectives.any( - (final validDirective) => directive.startsWith(validDirective), - ), - ); - - // Check for invalid directives - final invalidDirectives = directives.where( - (final directive) => !_validDirectives.any( - (final validDirective) => directive.startsWith(validDirective), - ), - ); - - if (!foundOneDirective || invalidDirectives.isNotEmpty) { - throw const FormatException('Invalid directive'); - } - - final bool noCache = directives.contains(_noCacheDirective); - final bool noStore = directives.contains(_noStoreDirective); - final bool mustRevalidate = directives.contains(_mustRevalidateDirective); - final bool proxyRevalidate = directives.contains(_proxyRevalidateDirective); - final bool noTransform = directives.contains(_noTransformDirective); - final bool onlyIfCached = directives.contains(_onlyIfCachedDirective); - final bool immutable = directives.contains(_immutableDirective); - final bool mustUnderstand = directives.contains(_mustUnderstandDirective); + bool noCache = false; + bool noStore = false; + bool mustRevalidate = false; + bool proxyRevalidate = false; + bool noTransform = false; + bool onlyIfCached = false; + bool immutable = false; + bool mustUnderstand = false; + bool publicCache = false; + bool privateCache = false; int? maxAge; int? staleWhileRevalidate; int? sMaxAge; int? staleIfError; int? maxStale; int? minFresh; - final bool publicCache = directives.contains(_publicDirective); - final bool privateCache = directives.contains(_privateDirective); for (final directive in directives) { - if (directive.startsWith('$_maxAgeDirective=')) { - maxAge = int.tryParse(directive.substring(_maxAgeDirective.length + 1)); - } else if (directive.startsWith('$_staleWhileRevalidateDirective=')) { - staleWhileRevalidate = int.tryParse( - directive.substring(_staleWhileRevalidateDirective.length + 1), - ); - } else if (directive.startsWith('$_sMaxAgeDirective=')) { - sMaxAge = int.tryParse( - directive.substring(_sMaxAgeDirective.length + 1), - ); - } else if (directive.startsWith('$_staleIfErrorDirective=')) { - staleIfError = int.tryParse( - directive.substring(_staleIfErrorDirective.length + 1), - ); - } else if (directive.startsWith('$_maxStaleDirective=')) { - maxStale = int.tryParse( - directive.substring(_maxStaleDirective.length + 1), - ); - } else if (directive.startsWith('$_minFreshDirective=')) { - minFresh = int.tryParse( - directive.substring(_minFreshDirective.length + 1), - ); + // Directive names are matched exactly (so `max-age-extended` is not + // mistaken for `max-age`) and case-insensitively (RFC 9111 5.2). The + // optional `="..."` argument is split off here; `no-cache`/`private` + // are recognized even when carrying a field-list. + final eq = directive.indexOf('='); + final name = (eq < 0 ? directive : directive.substring(0, eq)) + .trim() + .toLowerCase(); + final rawValue = eq < 0 ? null : directive.substring(eq + 1).trim(); + + // RFC 9111 5.2: a recipient MUST ignore cache directives it does not + // recognize (the directive set is extensible), rather than rejecting the + // whole header. Exact-name matching still keeps `max-age-extended` from + // being mistaken for `max-age`. + if (!_validDirectiveSet.contains(name)) { + continue; } - } - if (publicCache == true && privateCache == true) { - throw const FormatException('Cannot be both public and private'); + switch (name) { + case _noCacheDirective: + noCache = true; + case _noStoreDirective: + noStore = true; + case _mustRevalidateDirective: + mustRevalidate = true; + case _proxyRevalidateDirective: + proxyRevalidate = true; + case _noTransformDirective: + noTransform = true; + case _onlyIfCachedDirective: + onlyIfCached = true; + case _immutableDirective: + immutable = true; + case _mustUnderstandDirective: + mustUnderstand = true; + case _publicDirective: + publicCache = true; + case _privateDirective: + privateCache = true; + case _maxAgeDirective: + maxAge = _delta(rawValue); + case _staleWhileRevalidateDirective: + staleWhileRevalidate = _delta(rawValue); + case _sMaxAgeDirective: + sMaxAge = _delta(rawValue); + case _staleIfErrorDirective: + staleIfError = _delta(rawValue); + case _maxStaleDirective: + maxStale = _delta(rawValue); + case _minFreshDirective: + minFresh = _delta(rawValue); + } } - if (maxAge != null && staleWhileRevalidate != null) { - throw const FormatException( - 'Cannot have both max-age and stale-while-revalidate directives', - ); + if (publicCache && privateCache) { + throw const FormatException('Cannot be both public and private'); } return CacheControlHeader( diff --git a/packages/relic_core/lib/src/headers/typed/headers/clear_site_data_header.dart b/packages/relic_core/lib/src/headers/typed/headers/clear_site_data_header.dart index 917464ad..d7d3a637 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/clear_site_data_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/clear_site_data_header.dart @@ -73,7 +73,10 @@ class ClearSiteDataType { /// Private constructor for [ClearSiteDataType].P const ClearSiteDataType._(this.value); - /// Predefined Clear-Site-Data types. + /// Predefined Clear-Site-Data types per the [W3C spec][csd]: + /// `cache`, `cookies`, `storage`, `executionContexts`. + /// + /// [csd]: https://www.w3.org/TR/clear-site-data/#grammardef-type static const _cache = 'cache'; static const _cookies = 'cookies'; static const _storage = 'storage'; diff --git a/packages/relic_core/lib/src/headers/typed/headers/connection_header.dart b/packages/relic_core/lib/src/headers/typed/headers/connection_header.dart index e0ae305b..9b5eb63a 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/connection_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/connection_header.dart @@ -44,9 +44,6 @@ final class ConnectionHeader { /// Checks if the connection is marked as `upgrade`. bool get isUpgrade => directives.contains(ConnectionHeaderType.upgrade); - /// Checks if the connection is marked as `downgrade`. - bool get isDowngrade => directives.contains(ConnectionHeaderType.downgrade); - /// Converts the [ConnectionHeader] instance into a string representation suitable for HTTP headers. /// /// This method generates the header string by concatenating the connection directives. @@ -85,32 +82,23 @@ class ConnectionHeaderType { static const _keepAlive = 'keep-alive'; static const _close = 'close'; static const _upgrade = 'upgrade'; - static const _downgrade = 'downgrade'; static const keepAlive = ConnectionHeaderType._(_keepAlive); static const close = ConnectionHeaderType._(_close); static const upgrade = ConnectionHeaderType._(_upgrade); - static const downgrade = ConnectionHeaderType._(_downgrade); - /// Parses a [value] and returns the corresponding [ConnectionHeaderType] instance. - /// If the value does not match any predefined types, it returns a custom instance. + /// Parses a [value] into a [ConnectionHeaderType]. + /// + /// A `connection-option` is a `token` naming a hop-by-hop field (RFC 9110 + /// 7.6.1), so any valid token is accepted (e.g. `TE`, `Trailer`, custom + /// hop-by-hop headers), not only the predefined ones. Options are + /// case-insensitive and canonicalized to lowercase. factory ConnectionHeaderType.parse(final String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { throw const FormatException('Value cannot be empty'); } - switch (trimmed) { - case _keepAlive: - return keepAlive; - case _close: - return close; - case _upgrade: - return upgrade; - case _downgrade: - return downgrade; - default: - throw const FormatException('Invalid value'); - } + return ConnectionHeaderType._(Token.validate(trimmed).toLowerCase()); } @override diff --git a/packages/relic_core/lib/src/headers/typed/headers/content_encoding_header.dart b/packages/relic_core/lib/src/headers/typed/headers/content_encoding_header.dart index 6229f3b8..95dfbd11 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/content_encoding_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/content_encoding_header.dart @@ -90,30 +90,18 @@ class ContentEncoding { static const identity = ContentEncoding._(_identity); static const zstd = ContentEncoding._(_zstd); - /// Parses a [name] and returns the corresponding [ContentEncoding] instance. - /// If the name does not match any predefined encodings, it returns a custom - /// instance. + /// Parses a [name] into a [ContentEncoding]. + /// + /// The set of content-codings is an open IANA registry (RFC 9110 8.4.1), so + /// any valid `token` is accepted rather than only the predefined ones; this + /// lets recipients pass through unknown codings instead of failing. Codings + /// are case-insensitive, so the name is canonicalized to lowercase. factory ContentEncoding.parse(final String name) { final trimmed = name.trim(); if (trimmed.isEmpty) { throw const FormatException('Name cannot be empty'); } - switch (trimmed) { - case _gzip: - return gzip; - case _compress: - return compress; - case _deflate: - return deflate; - case _br: - return br; - case _identity: - return identity; - case _zstd: - return zstd; - default: - throw const FormatException('Invalid value'); - } + return ContentEncoding._(Token.validate(trimmed).toLowerCase()); } @override diff --git a/packages/relic_core/lib/src/headers/typed/headers/content_language_header.dart b/packages/relic_core/lib/src/headers/typed/headers/content_language_header.dart index 25925aef..b60ad283 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/content_language_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/content_language_header.dart @@ -30,10 +30,15 @@ final class ContentLanguageHeader { } final languages = splitValues.map((final language) { - if (!language.isValidLanguageCode()) { + // Validate against the full BCP 47 grammar (RFC 5646) rather than a + // narrow regex, so tags like `es-419`, `zh-Hant-TW`, and `x-foo` are + // accepted, and store the canonical-cased form so equality and hashing + // are case-insensitive (`en-US` == `EN-us`). + try { + return LanguageTag.parse(language).encode(); + } on FormatException { throw const FormatException('Invalid language code'); } - return language; }).toList(); return ContentLanguageHeader.languages(languages); diff --git a/packages/relic_core/lib/src/headers/typed/headers/content_range_header.dart b/packages/relic_core/lib/src/headers/typed/headers/content_range_header.dart index 11a2f50a..294578d1 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/content_range_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/content_range_header.dart @@ -23,10 +23,30 @@ final class ContentRangeHeader { final int? size; /// Constructs a [ContentRangeHeader] with the specified range and optional total size. + /// + /// Per RFC 9110 14.4, `start` and `end` must both be present (a specified + /// range) or both absent (an `unsatisfied-range`). The `unsatisfied-range` + /// form requires `size` (the `complete-length`); passing `size: null` with + /// no range is not representable on the wire. ContentRangeHeader({this.unit = 'bytes', this.start, this.end, this.size}) { + if ((start != null && start! < 0) || + (end != null && end! < 0) || + (size != null && size! < 0)) { + throw const FormatException('Content-Range members must not be negative'); + } + if ((start == null) != (end == null)) { + throw const FormatException( + 'start and end must both be set or both be null', + ); + } if (start != null && end != null && start! > end!) { throw const FormatException('Invalid range'); } + if (start == null && end == null && size == null) { + throw const FormatException( + 'unsatisfied-range form requires a complete-length (size)', + ); + } } /// Factory constructor to create a [ContentRangeHeader] from the header string. @@ -36,7 +56,9 @@ final class ContentRangeHeader { throw const FormatException('Value cannot be empty'); } - final regex = RegExp(r'(\w+) (?:(\d+)-(\d+)|\*)/(\*|\d+)'); + // Anchored so trailing (or leading) garbage is rejected rather than + // silently dropped by firstMatch extracting a partial prefix. + final regex = RegExp(r'^(\w+) (?:(\d+)-(\d+)|\*)/(\*|\d+)$'); final match = regex.firstMatch(trimmed); if (match == null) { @@ -44,19 +66,30 @@ final class ContentRangeHeader { } final unit = match.group(1)!; - final start = match.group(2) != null ? int.tryParse(match.group(2)!) : null; - final end = match.group(3) != null ? int.tryParse(match.group(3)!) : null; + // The regex guarantees these groups are all digits; parse them with a + // guard so an overflowing run throws a FormatException instead of + // int.tryParse silently yielding null (which would turn a specified range + // into an unsatisfied one) or int.parse throwing an unrelated error. + final start = _parseField(match.group(2)); + final end = _parseField(match.group(3)); if (start != null && end != null && start > end) { throw const FormatException('Invalid range'); } final sizeGroup = match.group(4)!; - - // If totalSize is '*', it means the total size is unknown - final size = sizeGroup == '*' ? null : int.parse(sizeGroup); + final size = sizeGroup == '*' ? null : _parseField(sizeGroup); return ContentRangeHeader(unit: unit, start: start, end: end, size: size); } + static int? _parseField(final String? digits) { + if (digits == null) return null; + final n = int.tryParse(digits); + if (n == null) { + throw FormatException('Content-Range value out of range', digits); + } + return n; + } + /// Returns the full content range string in the format "bytes start-end/totalSize". /// /// If the total size is unknown, it uses "*" in place of the total size. diff --git a/packages/relic_core/lib/src/headers/typed/headers/content_security_policy_header.dart b/packages/relic_core/lib/src/headers/typed/headers/content_security_policy_header.dart index 644a257b..70e642b2 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/content_security_policy_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/content_security_policy_header.dart @@ -87,7 +87,14 @@ class ContentSecurityPolicyDirective { /// Constructs a [ContentSecurityPolicyDirective] instance with the specified /// name and values. - ContentSecurityPolicyDirective({required this.name, required this.values}); + /// + /// CSP directive names are ASCII case-insensitive, so the name is + /// canonicalized to lowercase for case-insensitive equality. The values are + /// left untouched (source expressions can be case-sensitive). + ContentSecurityPolicyDirective({ + required final String name, + required this.values, + }) : name = name.toLowerCase(); /// Converts the [ContentSecurityPolicyDirective] instance into a string /// representation. diff --git a/packages/relic_core/lib/src/headers/typed/headers/cookie_header.dart b/packages/relic_core/lib/src/headers/typed/headers/cookie_header.dart index 5329069c..1c650e09 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/cookie_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/cookie_header.dart @@ -28,17 +28,16 @@ final class CookieHeader { final splitValues = value.splitTrimAndFilterUnique(separator: ';'); + // RFC 6265 5.4 allows a Cookie header to carry several cookies with the + // same name (e.g. a host-only cookie plus a Domain-scoped one, or + // path-scoped duplicates); the server cannot tell them apart from the + // header alone. Keep such same-name cookies rather than rejecting the + // whole header, so one duplicate name does not make an otherwise valid + // cookie unreadable. splitTrimAndFilterUnique still collapses byte-identical + // segments, which is harmless since they are indistinguishable. [getCookie] + // returns the first match. Cookie names are case-sensitive per RFC 6265 + // 4.2.2/5.4, so `Sid` and `sid` stay distinct. final cookies = splitValues.map(Cookie.parse).toList(); - final names = cookies - .map((final cookie) => cookie.name.toLowerCase()) - .toList(); - final uniqueNames = names.toSet(); - - if (names.length != uniqueNames.length) { - throw const FormatException( - 'Supplied multiple Name and Value attributes', - ); - } return CookieHeader.cookies(cookies); } @@ -82,12 +81,16 @@ class Cookie { value = validateCookieValue(value); factory Cookie.parse(final String value) { - final splitValue = value.split('='); - if (splitValue.length != 2) { + // Split on the FIRST '=' only; RFC 6265 cookie-octets permit '=' to + // appear inside the value (e.g. base64 padding). + final eq = value.indexOf('='); + if (eq < 0) { throw const FormatException('Invalid cookie format'); } - - return Cookie(name: splitValue.first.trim(), value: splitValue.last.trim()); + return Cookie( + name: value.substring(0, eq).trim(), + value: value.substring(eq + 1).trim(), + ); } /// Converts the [Cookie] instance into a string representation suitable for HTTP headers. diff --git a/packages/relic_core/lib/src/headers/typed/headers/cross_origin_embedder_policy_header.dart b/packages/relic_core/lib/src/headers/typed/headers/cross_origin_embedder_policy_header.dart index d7d1a02a..fc66bcc0 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/cross_origin_embedder_policy_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/cross_origin_embedder_policy_header.dart @@ -1,4 +1,7 @@ import '../../../../relic_core.dart'; +import 'util/report_to.dart'; + +const int _semicolon = 0x3B; /// A class representing the HTTP Cross-Origin-Embedder-Policy header. /// @@ -15,8 +18,11 @@ final class CrossOriginEmbedderPolicyHeader { /// The policy value of the header. final String policy; + /// The optional `report-to` reporting endpoint, if present. + final String? reportTo; + /// Constructs a [CrossOriginEmbedderPolicyHeader] instance with the specified value. - const CrossOriginEmbedderPolicyHeader._(this.policy); + const CrossOriginEmbedderPolicyHeader._(this.policy, [this.reportTo]); /// Predefined policy values. static const _unsafeNone = 'unsafe-none'; @@ -29,38 +35,43 @@ final class CrossOriginEmbedderPolicyHeader { _credentialless, ); - /// Parses a [value] and returns the corresponding [CrossOriginEmbedderPolicyHeader] instance. - /// If the value does not match any predefined types, it returns a custom instance. + static const _known = {_unsafeNone, _requireCorp, _credentialless}; + + /// Parses a [value] into a [CrossOriginEmbedderPolicyHeader]. + /// + /// The value is the policy token optionally followed by parameters, e.g. + /// `require-corp; report-to="endpoint"`. The `report-to` parameter is + /// captured; an unknown policy token is rejected. factory CrossOriginEmbedderPolicyHeader.parse(final String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { + if (value.trim().isEmpty) { throw const FormatException('Value cannot be empty'); } - - switch (trimmed) { - case _unsafeNone: - return unsafeNone; - case _requireCorp: - return requireCorp; - case _credentialless: - return credentialless; - default: - throw const FormatException('Invalid value'); + // Split parameters at top-level ';' only, so a ';' inside a quoted + // report-to value does not split the value. + final parts = HeaderScanner(value).splitTopLevel(_semicolon).toList(); + final token = parts.first.trim().toLowerCase(); + if (!_known.contains(token)) { + throw const FormatException('Invalid value'); } + final reportTo = parseReportToParam(parts.skip(1)); + return CrossOriginEmbedderPolicyHeader._(token, reportTo); } /// Converts the [CrossOriginEmbedderPolicyHeader] instance into a string /// representation suitable for HTTP headers. - String _encode() => policy; + String _encode() => + reportTo == null ? policy : '$policy; ${encodeReportToParam(reportTo!)}'; @override bool operator ==(final Object other) => identical(this, other) || - other is CrossOriginEmbedderPolicyHeader && policy == other.policy; + other is CrossOriginEmbedderPolicyHeader && + policy == other.policy && + reportTo == other.reportTo; @override - int get hashCode => policy.hashCode; + int get hashCode => Object.hash(policy, reportTo); @override String toString() { diff --git a/packages/relic_core/lib/src/headers/typed/headers/cross_origin_opener_policy_header.dart b/packages/relic_core/lib/src/headers/typed/headers/cross_origin_opener_policy_header.dart index e1b5c883..b4c32685 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/cross_origin_opener_policy_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/cross_origin_opener_policy_header.dart @@ -1,4 +1,7 @@ import '../../../../relic_core.dart'; +import 'util/report_to.dart'; + +const int _semicolon = 0x3B; /// A class representing the HTTP Cross-Origin-Opener-Policy header. /// @@ -15,52 +18,69 @@ final class CrossOriginOpenerPolicyHeader { /// The policy value of the header. final String policy; + /// The optional `report-to` reporting endpoint, if present. + final String? reportTo; + /// Constructs a [CrossOriginOpenerPolicyHeader] instance with the specified value. - const CrossOriginOpenerPolicyHeader._(this.policy); + const CrossOriginOpenerPolicyHeader._(this.policy, [this.reportTo]); /// Predefined policy values. static const _sameOrigin = 'same-origin'; static const _sameOriginAllowPopups = 'same-origin-allow-popups'; + static const _noopenerAllowPopups = 'noopener-allow-popups'; static const _unsafeNone = 'unsafe-none'; static const sameOrigin = CrossOriginOpenerPolicyHeader._(_sameOrigin); static const sameOriginAllowPopups = CrossOriginOpenerPolicyHeader._( _sameOriginAllowPopups, ); + static const noopenerAllowPopups = CrossOriginOpenerPolicyHeader._( + _noopenerAllowPopups, + ); static const unsafeNone = CrossOriginOpenerPolicyHeader._(_unsafeNone); - /// Parses a [value] and returns the corresponding [CrossOriginOpenerPolicyHeader] instance. - /// If the value does not match any predefined types, it returns a custom instance. + static const _known = { + _sameOrigin, + _sameOriginAllowPopups, + _noopenerAllowPopups, + _unsafeNone, + }; + + /// Parses a [value] into a [CrossOriginOpenerPolicyHeader]. + /// + /// The value is the policy token optionally followed by parameters, e.g. + /// `same-origin; report-to="endpoint"`. The `report-to` parameter is + /// captured; an unknown policy token is rejected. factory CrossOriginOpenerPolicyHeader.parse(final String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { + if (value.trim().isEmpty) { throw const FormatException('Value cannot be empty'); } - - switch (trimmed) { - case _sameOrigin: - return sameOrigin; - case _sameOriginAllowPopups: - return sameOriginAllowPopups; - case _unsafeNone: - return unsafeNone; - default: - throw const FormatException('Invalid value'); + // Split parameters at top-level ';' only, so a ';' inside a quoted + // report-to value does not split the value. + final parts = HeaderScanner(value).splitTopLevel(_semicolon).toList(); + final token = parts.first.trim().toLowerCase(); + if (!_known.contains(token)) { + throw const FormatException('Invalid value'); } + final reportTo = parseReportToParam(parts.skip(1)); + return CrossOriginOpenerPolicyHeader._(token, reportTo); } /// Converts the [CrossOriginOpenerPolicyHeader] instance into a string /// representation suitable for HTTP headers. - String _encode() => policy; + String _encode() => + reportTo == null ? policy : '$policy; ${encodeReportToParam(reportTo!)}'; @override bool operator ==(final Object other) => identical(this, other) || - other is CrossOriginOpenerPolicyHeader && policy == other.policy; + other is CrossOriginOpenerPolicyHeader && + policy == other.policy && + reportTo == other.reportTo; @override - int get hashCode => policy.hashCode; + int get hashCode => Object.hash(policy, reportTo); @override String toString() { diff --git a/packages/relic_core/lib/src/headers/typed/headers/etag_header.dart b/packages/relic_core/lib/src/headers/typed/headers/etag_header.dart index 9ca08ade..ce166d46 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/etag_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/etag_header.dart @@ -9,7 +9,7 @@ final class ETagHeader { static const codec = HeaderCodec.single(ETagHeader.parse, __encode); static List __encode(final ETagHeader value) => [value._encode()]; - /// The ETag value without quotes. + /// The opaque-tag value, without the surrounding quotes. final String value; /// Indicates whether the ETag is weak. @@ -22,39 +22,41 @@ final class ETagHeader { static const _weakPrefix = 'W/'; static const _quote = '"'; - /// Checks if a string is a valid ETag format (either strong or weak). + /// Checks whether [value] is a well-formed entity-tag (strong or weak) with a + /// valid `etagc` opaque-tag (RFC 9110 8.8.3). /// - /// Returns true if the string is either: - /// - A strong ETag: quoted string (e.g., "123456") - /// - A weak ETag: W/ followed by a quoted string (e.g., W/"123456") + /// Returns false (rather than throwing) for any malformed input, including + /// empty, so callers can use it to distinguish an ETag from, e.g., an + /// HTTP-date in `If-Range`. static bool isValidETag(final String value) { final trimmed = value.trim(); - if (trimmed.isEmpty) { - throw const FormatException('Value cannot be empty'); + if (trimmed.isEmpty) return false; + try { + ETagValue.parse(trimmed); + return true; + } on FormatException { + return false; } - - // Check for weak ETag format - if (trimmed.startsWith(_weakPrefix)) { - final tag = trimmed.substring(2).trim(); - return tag.startsWith(_quote) && tag.endsWith(_quote); - } - - // Check for strong ETag format - return trimmed.startsWith(_quote) && trimmed.endsWith(_quote); } /// Parses the ETag header value and returns an [ETagHeader] instance. /// - /// This method validates the format of the ETag string and parses - /// the ETag value and whether it is weak. + /// Delegates to the [ETagValue] primitive, which enforces the `etagc` grammar + /// (RFC 9110 8.8.3): the opaque-tag is taken from between the quotes without + /// stripping interior quotes, an interior `"` or CTL is rejected, and no + /// whitespace is allowed between `W/` and the opening quote. factory ETagHeader.parse(final String value) { - if (!isValidETag(value)) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + throw const FormatException('Value cannot be empty'); + } + final ETagValue etag; + try { + etag = ETagValue.parse(trimmed); + } on FormatException { throw const FormatException('Invalid format'); } - - final isWeak = value.startsWith(_weakPrefix); - final tagValue = isWeak ? value.substring(2).trim() : value.trim(); - return ETagHeader(value: tagValue.replaceAll(_quote, ''), isWeak: isWeak); + return ETagHeader(value: etag.value, isWeak: etag.isWeak); } /// Converts the [ETagHeader] instance into a string representation suitable diff --git a/packages/relic_core/lib/src/headers/typed/headers/expect_header.dart b/packages/relic_core/lib/src/headers/typed/headers/expect_header.dart index 5cf48e46..9994f158 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/expect_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/expect_header.dart @@ -20,18 +20,31 @@ final class ExpectHeader { static const continue100 = ExpectHeader._(_continue100); /// Parses a [value] and returns the corresponding [ExpectHeader] instance. - /// If the value does not match any predefined types, it returns a custom instance. + /// + /// Per RFC 9110 10.1.1 the only currently registered expectation is + /// `100-continue`, but the spec requires recipients to preserve unknown + /// expectations so a server can respond with `417 Expectation Failed` + /// rather than failing at the parse step. The matching of the known + /// `100-continue` token is case-insensitive. factory ExpectHeader.parse(final String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { throw const FormatException('Value cannot be empty'); } - switch (trimmed) { - case _continue100: - return continue100; - default: + if (trimmed.toLowerCase() == _continue100) { + return continue100; + } + // Unknown expectations are preserved (so a server can answer 417), but + // control characters are rejected so a CR/LF cannot be injected into the + // header when the value is later re-emitted. HTAB (0x09) is legal OWS in a + // field value and is allowed. + for (var i = 0; i < trimmed.length; i++) { + final c = trimmed.codeUnitAt(i); + if ((c <= 0x1F && c != 0x09) || c == 0x7F) { throw const FormatException('Invalid value'); + } } + return ExpectHeader._(trimmed); } /// Converts the [ExpectHeader] instance into a string representation diff --git a/packages/relic_core/lib/src/headers/typed/headers/from_header.dart b/packages/relic_core/lib/src/headers/typed/headers/from_header.dart index 0a94e671..e9482ada 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/from_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/from_header.dart @@ -1,60 +1,43 @@ -import 'package:collection/collection.dart'; - import '../../../../relic_core.dart'; -import '../../extension/string_list_extensions.dart'; /// A class representing the HTTP `From` header. /// -/// The `From` header is used to indicate the email address of the user making the request. -/// It usually contains a single email address, but in edge cases, it could contain multiple -/// email addresses separated by commas. +/// Per RFC 9110 10.1.2 the `From` header is a single `mailbox` -- the email +/// address of the human controlling the requesting user agent. The full +/// RFC 5322 `mailbox` syntax is richer than a bare `addr-spec` (it also allows +/// a `name-addr` like `Webmaster `, whose display-name +/// may even contain a comma), so the value is preserved verbatim rather than +/// split or format-validated. final class FromHeader { - static const codec = HeaderCodec(FromHeader.parse, __encode); - static List __encode(final FromHeader value) => [value._encode()]; - - /// A list of email addresses provided in the `From` header. - final List emails; - - /// Private constructor for initializing the [emails] list. - FromHeader.emails(final Iterable emails) - : assert(emails.isNotEmpty), - emails = List.unmodifiable(emails); - - /// Parses a `From` header value and returns a [FromHeader] instance. - factory FromHeader.parse(final Iterable values) { - final emails = values.splitTrimAndFilterUnique(); - if (emails.isEmpty) { + static const codec = HeaderCodec.single(FromHeader.parse, __encode); + static List __encode(final FromHeader value) => [value.mailbox]; + + /// The single mailbox value (RFC 9110 10.1.2 `From = mailbox`). + final String mailbox; + + /// Constructs a [FromHeader] from a [mailbox] value. + const FromHeader(this.mailbox); + + /// Parses a `From` header value into a [FromHeader]. + /// + /// The value is a single mailbox; it is not split on `,` (a `display-name` + /// may legitimately contain one) and is kept as-is, since an unparseable + /// mailbox must not fail the request. + factory FromHeader.parse(final String value) { + final mailbox = value.trim(); + if (mailbox.isEmpty) { throw const FormatException('Value cannot be empty'); } - - for (final email in emails) { - if (!email.isValidEmail()) { - throw const FormatException('Invalid email format'); - } - } - - return FromHeader.emails(emails); + return FromHeader(mailbox); } - /// Returns the single email address if the list only contains one email. - String? get singleEmail => emails.length == 1 ? emails.first : null; - - /// Converts the [FromHeader] instance into a string representation - /// suitable for HTTP headers. - - String _encode() => emails.join(', '); - @override bool operator ==(final Object other) => - identical(this, other) || - other is FromHeader && - const ListEquality().equals(emails, other.emails); + identical(this, other) || other is FromHeader && mailbox == other.mailbox; @override - int get hashCode => const ListEquality().hash(emails); + int get hashCode => mailbox.hashCode; @override - String toString() { - return 'FromHeader(emails: $emails)'; - } + String toString() => 'FromHeader(mailbox: $mailbox)'; } diff --git a/packages/relic_core/lib/src/headers/typed/headers/host_header.dart b/packages/relic_core/lib/src/headers/typed/headers/host_header.dart index 40fc507f..94fc2e2e 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/host_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/host_header.dart @@ -10,8 +10,14 @@ final class HostHeader { HostHeader._(this.host, this.port); factory HostHeader(final String host, [final int? port]) { - RangeError.checkValueInInterval(port ?? 0, 0, 65535); - return HostHeader._(host.trim().toLowerCase(), port); + final trimmed = host.trim(); + if (trimmed.isEmpty) { + throw const FormatException('Host cannot be empty'); + } + if (port != null && (port < 0 || port > 65535)) { + throw FormatException('Port out of range', port.toString()); + } + return HostHeader._(trimmed.toLowerCase(), port); } factory HostHeader.parse(String value) { @@ -50,15 +56,54 @@ final class HostHeader { // no port return HostHeader(host, null); } else { - final port = int.parse(value.substring(lastColon + 1)); - if (port < 0 || port > 65535) { - throw FormatException('Port out of range', value); + return HostHeader( + host, + _parsePort(value.substring(lastColon + 1), value), + ); + } + } + + /// Parses a port as digits only (RFC 9110 7.2 `port = *DIGIT`), throwing + /// [FormatException] for a non-digit, empty, or out-of-range value. Unlike + /// `int.parse`, this rejects `0x..`, a leading sign, and surrounding + /// whitespace, and reports a consistent 'Port out of range' message. + static int _parsePort(final String s, final String source) { + if (s.isEmpty) { + throw FormatException('Port cannot be empty', source); + } + if (s.length > 5) { + throw FormatException('Port out of range', source); + } + var v = 0; + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c < 0x30 || c > 0x39) { + throw FormatException('Port must contain only digits', source); } - return HostHeader(host, port); + v = v * 10 + (c - 0x30); } + if (v > 65535) { + throw FormatException('Port out of range', source); + } + return v; } - HostHeader.fromUri(final Uri uri) : this._(uri.host, uri.port); + /// Constructs a [HostHeader] from a [Uri]. + /// + /// Preserves the absence of an explicit port: a URI like + /// `http://example.com/` produces `port: null` rather than the default + /// `80`, matching RFC 9110 7.2 (`Host = uri-host [ ":" port ]`). + /// + /// Dart's [Uri.host] returns an IPv6 address without its brackets, but the + /// `Host` wire form requires them (RFC 3986 `host = IP-literal`). They are + /// re-added here so [HostHeader.fromUri] matches [HostHeader.parse] (which + /// keeps the brackets) and encodes unambiguous syntax. Routing through the + /// public factory also applies the same normalization and port-range check + /// as the other constructors. + factory HostHeader.fromUri(final Uri uri) { + final host = uri.host.contains(':') ? '[${uri.host}]' : uri.host; + return HostHeader(host, uri.hasPort ? uri.port : null); + } String _encode() => port == null ? host : '$host:$port'; diff --git a/packages/relic_core/lib/src/headers/typed/headers/if_range_header.dart b/packages/relic_core/lib/src/headers/typed/headers/if_range_header.dart index e9f7f6bc..ecd6a8ab 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/if_range_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/if_range_header.dart @@ -18,7 +18,10 @@ final class IfRangeHeader { /// Constructs an [IfRangeHeader] instance with either a date or an ETag. /// - /// Either [lastModified] or [etag] must be non-null. + /// Either [lastModified] or [etag] must be non-null. A weak ETag is accepted + /// but can never satisfy a range request: RFC 9110 13.1.5 requires a strong + /// validator, so a consumer treats a weak `If-Range` as a no-match and serves + /// the full representation rather than rejecting the request. IfRangeHeader({this.lastModified, this.etag}) { if (lastModified == null && etag == null) { throw const FormatException('Either date or etag must be provided'); @@ -34,7 +37,6 @@ final class IfRangeHeader { throw const FormatException('Value cannot be empty'); } - // Check if the value is a valid ETag if (ETagHeader.isValidETag(trimmed)) { return IfRangeHeader(etag: ETagHeader.parse(trimmed)); } diff --git a/packages/relic_core/lib/src/headers/typed/headers/permission_policy_header.dart b/packages/relic_core/lib/src/headers/typed/headers/permission_policy_header.dart index a779c2f4..8d18819c 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/permission_policy_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/permission_policy_header.dart @@ -1,7 +1,9 @@ import 'package:collection/collection.dart'; import '../../../../relic_core.dart'; -import '../../extension/string_list_extensions.dart'; + +const int _comma = 0x2C; +const int _space = 0x20; /// A class representing the HTTP Permissions-Policy header. /// @@ -20,41 +22,82 @@ final class PermissionsPolicyHeader { final List directives; /// Constructs a [PermissionsPolicyHeader] instance with the specified directives. + /// + /// Every directive must have a non-empty feature name, otherwise the header + /// would encode to a malformed `=()` that no conforming user agent (nor + /// [PermissionsPolicyHeader.parse]) accepts. PermissionsPolicyHeader.directives( final List directives, - ) : assert(directives.isNotEmpty), - directives = List.unmodifiable(directives); + ) : directives = List.unmodifiable(directives) { + if (this.directives.isEmpty) { + throw const FormatException( + 'Permissions-Policy requires at least one directive', + ); + } + for (final d in this.directives) { + if (d.name.isEmpty) { + throw const FormatException('Permissions-Policy feature name is empty'); + } + // The feature name is a token; a value like `geolocation allow` (with a + // space) would otherwise serialize as a malformed `name=(...)` directive. + Token.validate(d.name); + } + } /// Parses the Permissions-Policy header value and returns a [PermissionsPolicyHeader] instance. /// /// This method splits the header value by commas, trims each directive, /// and processes the directive and its values. factory PermissionsPolicyHeader.parse(final String value) { - final splitValues = value.splitTrimAndFilterUnique(separator: ','); - if (splitValues.isEmpty) { + if (value.trim().isEmpty) { throw const FormatException('Value cannot be empty'); } final directives = []; - for (final part in splitValues) { - final directiveParts = part.split('='); - final name = directiveParts.first.trim(); - final values = directiveParts.length > 1 - ? directiveParts[1] - .replaceAll('(', '') - .replaceAll(')', '') - .split(' ') - .map((final s) => s.trim()) - .where((final s) => s.isNotEmpty) - .toList() - : []; - - directives.add(PermissionsPolicyDirective(name: name, values: values)); + // Split directives at top-level commas only, so a comma inside an + // sf-string value does not split a directive (RFC 8941 dictionary). + for (final part in HeaderScanner(value).splitTopLevel(_comma)) { + if (part.isEmpty) continue; + // The first '=' separates the feature name from its inner-list value; + // an sf-string in the value may itself contain '=' (e.g. a URL query). + final eq = part.indexOf('='); + final name = (eq < 0 ? part : part.substring(0, eq)).trim(); + final rawList = eq < 0 ? '' : part.substring(eq + 1).trim(); + directives.add( + PermissionsPolicyDirective( + name: name, + values: _parseInnerList(rawList), + ), + ); } + // The non-empty invariant is enforced by the .directives() constructor. return PermissionsPolicyHeader.directives(directives); } + /// Parses an inner-list value `(item item ...)` (or a bare single item) + /// into its component sf-tokens / sf-strings, splitting on top-level + /// whitespace so an sf-string containing a space stays intact. + static List _parseInnerList(final String raw) { + var inner = raw; + final hasOpen = inner.startsWith('('); + final hasClose = inner.endsWith(')'); + if (hasOpen && hasClose) { + inner = inner.substring(1, inner.length - 1); + } else if (hasOpen || hasClose) { + // An inner-list parenthesis is unbalanced; reject rather than keep the + // stray paren as part of a token value. + throw FormatException('unbalanced Permissions-Policy inner-list', raw); + } + inner = inner.trim(); + if (inner.isEmpty) return const []; + return HeaderScanner(inner) + .splitTopLevel(_space) + .where((final s) => s.isNotEmpty) + .map(_unquote) + .toList(); + } + /// Converts the [PermissionsPolicyHeader] instance into a string /// representation suitable for HTTP headers. @@ -81,6 +124,16 @@ final class PermissionsPolicyHeader { } } +String _unquote(final String s) { + if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) { + return s + .substring(1, s.length - 1) + .replaceAll(r'\"', '"') + .replaceAll(r'\\', r'\'); + } + return s; +} + /// A class representing a single Permissions-Policy directive. class PermissionsPolicyDirective { /// The name of the directive (e.g., `geolocation`, `microphone`). @@ -92,12 +145,36 @@ class PermissionsPolicyDirective { /// Constructs a [PermissionsPolicyDirective] instance with the specified name and values. const PermissionsPolicyDirective({required this.name, required this.values}); - /// Converts the [PermissionsPolicyDirective] instance into a string representation. + /// Converts the [PermissionsPolicyDirective] instance into a string + /// representation. + /// + /// Per the W3C Permissions Policy spec, the header is an RFC 8941 + /// Structured Field Dictionary whose values are inner-lists of sf-tokens + /// and sf-strings. The tokens `*` and `self` appear bare; URL origins MUST + /// be serialized as sf-strings (RFC 8941 String), i.e. quoted with + /// `"..."`. Without this distinction, conforming user agents drop the + /// entire directive because `https://example.com` is not a valid sf-token. String _encode() { - final valuesStr = values.isNotEmpty ? '(${values.join(' ')})' : '()'; + final rendered = values.map(_renderItem).toList(); + final valuesStr = rendered.isNotEmpty ? '(${rendered.join(' ')})' : '()'; return '$name=$valuesStr'; } + static String _renderItem(final String v) { + for (var i = 0; i < v.length; i++) { + final c = v.codeUnitAt(i); + // Reject CTLs so a value built from untrusted input cannot inject a + // CR/LF (or other control byte) into the serialized header. + if (c <= 0x1F || c == 0x7F) { + throw const FormatException( + 'Permissions-Policy value must not contain control characters', + ); + } + } + if (v == '*' || v == 'self') return v; + return '"${v.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}"'; + } + @override bool operator ==(final Object other) => identical(this, other) || diff --git a/packages/relic_core/lib/src/headers/typed/headers/range_header.dart b/packages/relic_core/lib/src/headers/typed/headers/range_header.dart index 4de9767a..cc1005fe 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/range_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/range_header.dart @@ -109,6 +109,11 @@ class Range { 'At least one of start or end must be specified', ); } + // An inverted range (last-pos < first-pos) is represented faithfully + // rather than rejected. RFC 9110 14.2 has the recipient ignore or answer + // 416 for a range it cannot satisfy, so satisfiability against a concrete + // resource is the consumer's call (e.g. a file handler returning 416), + // not the parser's. } /// Converts the [Range] instance into a string representation suitable diff --git a/packages/relic_core/lib/src/headers/typed/headers/referrer_policy_header.dart b/packages/relic_core/lib/src/headers/typed/headers/referrer_policy_header.dart index ff0dadaa..a635988a 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/referrer_policy_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/referrer_policy_header.dart @@ -41,34 +41,39 @@ final class ReferrerPolicyHeader { ); static const unsafeUrl = ReferrerPolicyHeader._(_unsafeUrl); - /// Parses a [directive] and returns the corresponding [ReferrerPolicyHeader] instance. - /// If the directive does not match any predefined types, it returns a custom instance. + static const Map _byName = { + _noReferrer: noReferrer, + _noReferrerWhenDowngrade: noReferrerWhenDowngrade, + _origin: origin, + _originWhenCrossOrigin: originWhenCrossOrigin, + _sameOrigin: sameOrigin, + _strictOrigin: strictOrigin, + _strictOriginWhenCrossOrigin: strictOriginWhenCrossOrigin, + _unsafeUrl: unsafeUrl, + }; + + /// Parses a Referrer-Policy header value. + /// + /// Per the W3C Referrer Policy spec, the value is a comma-separated list of + /// policy tokens processed left-to-right, keeping the last one the user + /// agent recognizes (so a deployment can list `no-referrer, strict-origin` + /// as a fallback for older agents). Unknown tokens are ignored, and the + /// match is case-insensitive. factory ReferrerPolicyHeader.parse(final String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty) { + if (value.trim().isEmpty) { throw const FormatException('Value cannot be empty'); } - switch (trimmed) { - case _noReferrer: - return noReferrer; - case _noReferrerWhenDowngrade: - return noReferrerWhenDowngrade; - case _origin: - return origin; - case _originWhenCrossOrigin: - return originWhenCrossOrigin; - case _sameOrigin: - return sameOrigin; - case _strictOrigin: - return strictOrigin; - case _strictOriginWhenCrossOrigin: - return strictOriginWhenCrossOrigin; - case _unsafeUrl: - return unsafeUrl; - default: - throw const FormatException('Invalid value'); + ReferrerPolicyHeader? lastValid; + for (final token in value.split(',')) { + final policy = _byName[token.trim().toLowerCase()]; + if (policy != null) lastValid = policy; + } + + if (lastValid == null) { + throw const FormatException('No valid referrer policy directive'); } + return lastValid; } /// Converts the [ReferrerPolicyHeader] instance into a string diff --git a/packages/relic_core/lib/src/headers/typed/headers/retry_after_header.dart b/packages/relic_core/lib/src/headers/typed/headers/retry_after_header.dart index 4c1980dd..fe53cbfa 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/retry_after_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/retry_after_header.dart @@ -39,19 +39,19 @@ final class RetryAfterHeader { throw const FormatException('Value cannot be empty'); } - final delay = int.tryParse(trimmed); - if (delay != null) { - if (delay < 0) { + // A numeric value is a delay (delta-seconds = 1*DIGIT). Reject a leading + // sign: `-120` is negative and `+120` is not a valid delta-seconds. + if (RegExp(r'^[+-]?[0-9]+$').hasMatch(trimmed)) { + if (trimmed.startsWith('-')) { throw const FormatException('Delay cannot be negative'); } - return RetryAfterHeader(delay: delay); - } else { - try { - final date = parseHttpDate(trimmed); - return RetryAfterHeader(date: date); - } catch (e) { - throw const FormatException('Invalid date format'); - } + return RetryAfterHeader(delay: DeltaSeconds.parse(trimmed).seconds); + } + try { + final date = parseHttpDate(trimmed); + return RetryAfterHeader(date: date); + } catch (e) { + throw const FormatException('Invalid date format'); } } diff --git a/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_dest_header.dart b/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_dest_header.dart index 80d119cc..ce95fe4c 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_dest_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_dest_header.dart @@ -64,8 +64,9 @@ final class SecFetchDestHeader { static const worker = SecFetchDestHeader._(_worker); static const xslt = SecFetchDestHeader._(_xslt); - /// Parses a [value] and returns the corresponding [SecFetchDestHeader] instance. - /// If the value does not match any predefined types, it returns a custom instance. + /// Parses a [value] into a [SecFetchDestHeader]. A value outside the + /// predefined set is rejected; new Fetch values are adopted by updating the + /// set in a relic release rather than accepted ad hoc. factory SecFetchDestHeader.parse(final String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { diff --git a/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_mode_header.dart b/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_mode_header.dart index 2a6379ec..4fe92380 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_mode_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_mode_header.dart @@ -15,23 +15,25 @@ final class SecFetchModeHeader { /// Private constructor for [SecFetchModeHeader]. const SecFetchModeHeader._(this.mode); - /// Predefined mode values. + /// Predefined mode values per the [Fetch Standard][fetch]: `cors`, + /// `no-cors`, `same-origin`, `navigate`, `websocket`. + /// + /// [fetch]: https://fetch.spec.whatwg.org/#concept-request-mode static const _cors = 'cors'; static const _noCors = 'no-cors'; static const _sameOrigin = 'same-origin'; static const _navigate = 'navigate'; - static const _nestedNavigate = 'nested-navigate'; static const _webSocket = 'websocket'; static const cors = SecFetchModeHeader._(_cors); static const noCors = SecFetchModeHeader._(_noCors); static const sameOrigin = SecFetchModeHeader._(_sameOrigin); static const navigate = SecFetchModeHeader._(_navigate); - static const nestedNavigate = SecFetchModeHeader._(_nestedNavigate); static const webSocket = SecFetchModeHeader._(_webSocket); - /// Parses a [value] and returns the corresponding [SecFetchModeHeader] instance. - /// If the value does not match any predefined types, it returns a custom instance. + /// Parses a [value] into a [SecFetchModeHeader]. A value outside the + /// predefined set is rejected; new Fetch values are adopted by updating the + /// set in a relic release rather than accepted ad hoc. factory SecFetchModeHeader.parse(final String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { @@ -47,8 +49,6 @@ final class SecFetchModeHeader { return sameOrigin; case _navigate: return navigate; - case _nestedNavigate: - return nestedNavigate; case _webSocket: return webSocket; default: diff --git a/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_site_header.dart b/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_site_header.dart index 92f69064..99f77c5c 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_site_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/sec_fetch_site_header.dart @@ -27,8 +27,9 @@ final class SecFetchSiteHeader { static const crossSite = SecFetchSiteHeader._(_crossSite); static const none = SecFetchSiteHeader._(_none); - /// Parses a [value] and returns the corresponding [SecFetchSiteHeader] instance. - /// If the value does not match any predefined types, it returns a custom instance. + /// Parses a [value] into a [SecFetchSiteHeader]. A value outside the + /// predefined set is rejected; new Fetch values are adopted by updating the + /// set in a relic release rather than accepted ad hoc. factory SecFetchSiteHeader.parse(final String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { diff --git a/packages/relic_core/lib/src/headers/typed/headers/set_cookie_header.dart b/packages/relic_core/lib/src/headers/typed/headers/set_cookie_header.dart index 53beaecb..1fdfacd4 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/set_cookie_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/set_cookie_header.dart @@ -36,10 +36,17 @@ final class SetCookieHeader { final int? maxAge; /// The domain that the cookie applies to. - final Uri? domain; + /// + /// Per RFC 6265 5.2.3, this is a bare hostname (``): no scheme, + /// no leading slashes, no port, no path. Modeling it as [Host] enforces + /// that on construction and on the wire. + final Host? domain; /// The path within the [domain] that the cookie applies to. - final Uri? path; + /// + /// Per RFC 6265 5.2.4, this is an opaque string (any CHAR except CTLs or + /// `;`); it is not a URI. + final String? path; /// Whether to only send this cookie on secure connections. final bool secure; @@ -56,18 +63,33 @@ final class SetCookieHeader { final SameSite? sameSite; /// Constructs a [Cookie] instance with the specified name and value. + /// + /// Per RFC 6265 5.2.3 the `Domain` attribute is host-only, so [domain] must + /// not carry a port; a [Host] with a port throws [FormatException]. [path] + /// is validated against the path-value grammar (no CTLs or `;`). SetCookieHeader({ required final String name, required final String value, this.expires, this.maxAge, - this.domain, - this.path, + final Host? domain, + final String? path, this.secure = false, this.httpOnly = false, this.sameSite, }) : name = validateCookieName(name), - value = validateCookieValue(value); + value = validateCookieValue(value), + domain = _validateCookieDomain(domain), + path = path == null ? null : validateCookiePath(path); + + static Host? _validateCookieDomain(final Host? domain) { + if (domain != null && domain.port != null) { + throw const FormatException( + 'Cookie Domain must not include a port (RFC 6265 5.2.3)', + ); + } + return domain; + } factory SetCookieHeader.parse(final String value) { final splitValue = value.splitTrimAndFilterUnique(separator: ';'); @@ -75,99 +97,70 @@ final class SetCookieHeader { throw const FormatException('Value cannot be empty'); } + // RFC 6265 5.2: the first ';'-delimited token is the cookie-pair; every + // subsequent token is a cookie attribute (cookie-av). Splitting on the + // first '=' keeps a '=' that appears inside the value or an attribute. + final pair = splitValue.first; + final pairEq = pair.indexOf('='); + if (pairEq < 0) { + throw const FormatException('Invalid cookie format'); + } + final cookieName = pair.substring(0, pairEq).trim(); + final cookieValue = pair.substring(pairEq + 1).trim(); + bool secure = false; bool httpOnly = false; - String cookieName = ''; - String cookieValue = ''; SameSite? sameSite; DateTime? expires; int? maxAge; - Uri? domain; - Uri? path; - - for (final cookie in splitValue) { - // Handle SameSite attribute - if (cookie.toLowerCase().contains(_sameSite.toLowerCase())) { - if (sameSite != null) { - throw const FormatException('Supplied multiple SameSite attributes'); - } - final samesiteValue = cookie.split('=')[1].trim(); - sameSite = SameSite.values.firstWhere( - (final sameSite) => - sameSite.name.toLowerCase() == samesiteValue.toLowerCase(), - orElse: () => - throw const FormatException('Invalid SameSite attribute'), - ); - continue; - } - - // Handle Path attribute; - if (cookie.toLowerCase().contains(_path.toLowerCase())) { - if (path != null) { - throw const FormatException('Supplied multiple Path attributes'); - } - final pathValue = cookie.split('=')[1].trim(); - path = parseUri(pathValue); - continue; - } - - // Handle Domain attribute - if (cookie.toLowerCase().contains(_domain.toLowerCase())) { - if (domain != null) { - throw const FormatException('Supplied multiple Domain attributes'); - } - final domainValue = cookie.split('=')[1].trim(); - domain = parseUri(domainValue); - continue; - } - - // Handle Max-Age attribute - if (cookie.toLowerCase().contains(_maxAge.toLowerCase())) { - if (maxAge != null) { - throw const FormatException('Supplied multiple Max-Age attributes'); - } - final maxAgeValue = cookie.split('=')[1].trim(); - maxAge = parseInt(maxAgeValue); - continue; - } - - // Handle Expires attribute - if (cookie.toLowerCase().contains(_expires.toLowerCase())) { - if (expires != null) { - throw const FormatException('Supplied multiple Expires attributes'); - } - final expiresValue = cookie.split('=')[1].trim(); - expires = parseDate(expiresValue); - continue; - } - - // Handle Secure attribute - if (cookie.toLowerCase().contains(_secure.toLowerCase())) { - secure = true; - continue; - } - - // Handle HttpOnly attribute - if (cookie.toLowerCase().contains(_httpOnly.toLowerCase())) { - httpOnly = true; - continue; - } - - // Handle Name and Value - // If non of the other attributes are present, then the cookie is a name and value pair - if (cookie.contains('=')) { - if (cookieName.isNotEmpty || cookieValue.isNotEmpty) { - throw const FormatException( - 'Supplied multiple Name and Value attributes', + Host? domain; + String? path; + + for (final av in splitValue.skip(1)) { + final eq = av.indexOf('='); + final attrName = (eq < 0 ? av : av.substring(0, eq)).trim(); + final attrValue = eq < 0 ? '' : av.substring(eq + 1).trim(); + + switch (attrName.toLowerCase()) { + case 'samesite': + if (sameSite != null) { + throw const FormatException( + 'Supplied multiple SameSite attributes', + ); + } + sameSite = SameSite.values.firstWhere( + (final s) => s.name.toLowerCase() == attrValue.toLowerCase(), + orElse: () => + throw const FormatException('Invalid SameSite attribute'), ); - } - final parts = cookie.split('='); - cookieName = parts.first.trim(); - cookieValue = parts.last.trim(); - continue; + case 'path': + if (path != null) { + throw const FormatException('Supplied multiple Path attributes'); + } + path = attrValue; + case 'domain': + if (domain != null) { + throw const FormatException('Supplied multiple Domain attributes'); + } + domain = Host.parse(attrValue); + case 'max-age': + if (maxAge != null) { + throw const FormatException('Supplied multiple Max-Age attributes'); + } + maxAge = parseInt(attrValue); + case 'expires': + if (expires != null) { + throw const FormatException('Supplied multiple Expires attributes'); + } + expires = parseDate(attrValue); + case 'secure': + secure = true; + case 'httponly': + httpOnly = true; + default: + // RFC 6265 5.2: ignore unrecognized attributes (e.g. future tokens + // like `Partitioned`, `Priority`) rather than failing the parse. } - - throw const FormatException('Invalid cookie format'); } return SetCookieHeader( @@ -186,9 +179,14 @@ final class SetCookieHeader { /// Converts the [Cookie] instance into a string representation suitable for HTTP headers. String _encode() { - // Use a set to ensure unique attributes - final attributes = {}; - if (name.isNotEmpty) attributes.add('$name=$value'); + // Each attribute is emitted at most once by construction, so a plain list + // is correct here; a Set would silently collapse a cookie-pair whose name + // coincides with an attribute rendering (e.g. name 'Path', value '/x'). + final attributes = []; + // Always emit the cookie-pair first, even for the empty-name `=value` + // quirk, so that encode round-trips with parse (which requires the first + // token to contain '='). + attributes.add('$name=$value'); if (secure) attributes.add(_secure); if (httpOnly) attributes.add(_httpOnly); @@ -197,8 +195,8 @@ final class SetCookieHeader { attributes.add('$_expires${formatHttpDate(expires!)}'); } if (maxAge != null) attributes.add('$_maxAge$maxAge'); - if (domain != null) attributes.add('$_domain${domain.toString()}'); - if (path != null) attributes.add('$_path${path.toString()}'); + if (domain != null) attributes.add('$_domain${domain!.encode()}'); + if (path != null) attributes.add('$_path$path'); return attributes.join('; '); } diff --git a/packages/relic_core/lib/src/headers/typed/headers/strict_transport_security_header.dart b/packages/relic_core/lib/src/headers/typed/headers/strict_transport_security_header.dart index f6b8b1dd..1ebb2736 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/strict_transport_security_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/strict_transport_security_header.dart @@ -31,7 +31,8 @@ final class StrictTransportSecurityHeader { }); /// Predefined directive values. - static const _maxAgePrefix = 'max-age='; + static const _maxAgeName = 'max-age'; + static const _maxAgePrefix = '$_maxAgeName='; static const _includeSubDomains = 'includeSubDomains'; static const _preload = 'preload'; @@ -48,12 +49,30 @@ final class StrictTransportSecurityHeader { bool includeSubDomains = false; bool preload = false; + // RFC 6797 6.1: directive names are case-insensitive; `max-age` is a + // non-negative integer that may be supplied as a quoted-string. Split the + // name from its optional value on the first `=`, tolerating OWS around it + // (e.g. `max-age = 31536000`), and ignore unknown directives. for (final directive in splitValues) { - if (directive.startsWith(_maxAgePrefix)) { - maxAge = int.tryParse(directive.substring(_maxAgePrefix.length)); - } else if (directive == _includeSubDomains) { + final eq = directive.indexOf('='); + final name = (eq < 0 ? directive : directive.substring(0, eq)) + .trim() + .toLowerCase(); + if (name == _maxAgeName) { + var v = eq < 0 ? '' : directive.substring(eq + 1).trim(); + if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) { + v = v.substring(1, v.length - 1); + } + try { + maxAge = DeltaSeconds.parse(v).seconds; + } on FormatException { + throw const FormatException( + 'Max-age directive is missing or invalid', + ); + } + } else if (name == _includeSubDomains.toLowerCase()) { includeSubDomains = true; - } else if (directive == _preload) { + } else if (name == _preload.toLowerCase()) { preload = true; } } diff --git a/packages/relic_core/lib/src/headers/typed/headers/te_header.dart b/packages/relic_core/lib/src/headers/typed/headers/te_header.dart index 58527405..89072b33 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/te_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/te_header.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import '../../../../relic_core.dart'; import '../../extension/string_list_extensions.dart'; +import 'util/qvalue.dart'; /// A class representing the HTTP TE header. /// @@ -31,18 +32,28 @@ final class TEHeader { } final encodings = splitValues.map((final value) { - final encodingParts = value.split(';q='); - final encoding = encodingParts[0].trim().toLowerCase(); + final parts = value.split(';'); + final encoding = parts[0].trim().toLowerCase(); if (encoding.isEmpty) { throw const FormatException('Invalid encoding'); } double? quality; - if (encodingParts.length > 1) { - final value = double.tryParse(encodingParts[1].trim()); - if (value == null || value < 0 || value > 1) { - throw const FormatException('Invalid quality value'); + // Per RFC 9110 12.4.2 the weight parameter is `q`, case-insensitive, + // with OWS allowed around the surrounding `;` and `=`. + for (var i = 1; i < parts.length; i++) { + final eq = parts[i].indexOf('='); + if (eq < 0) continue; + final name = parts[i].substring(0, eq).trim(); + if (name.toLowerCase() != 'q') continue; + final parsed = double.tryParse(parts[i].substring(eq + 1).trim()); + // A malformed or out-of-range weight is treated as absent (defaulting + // to 1.0) rather than rejecting the whole header: the client did list + // this entry, so it is acceptable; only the unparseable preference is + // dropped (RFC 9110 12.4.2; robustness on received headers). + if (parsed != null && parsed >= 0 && parsed <= 1) { + quality = parsed; } - quality = value; + break; } return TeQuality(encoding, quality); }).toList(); @@ -79,8 +90,11 @@ class TeQuality { /// Constructs an instance of [TeQuality]. TeQuality(this.encoding, [final double? quality]) : quality = quality ?? 1.0; - /// Converts the [TeQuality] instance into a string representation suitable for HTTP headers. - String _encode() => quality == 1.0 ? encoding : '$encoding;q=$quality'; + /// Converts the [TeQuality] instance into a string representation suitable + /// for HTTP headers. The q-value is rendered with at most 3 fractional + /// digits per RFC 9110 12.4.2. + String _encode() => + quality == 1.0 ? encoding : '$encoding;q=${formatQValue(quality!)}'; @override bool operator ==(final Object other) => diff --git a/packages/relic_core/lib/src/headers/typed/headers/transfer_encoding_header.dart b/packages/relic_core/lib/src/headers/typed/headers/transfer_encoding_header.dart index 9a395e7d..19ac8876 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/transfer_encoding_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/transfer_encoding_header.dart @@ -16,11 +16,24 @@ final class TransferEncodingHeader { final List encodings; /// Constructs a [TransferEncodingHeader] instance with the specified transfer encodings. + /// + /// Per RFC 9112 6.1 the `chunked` transfer-coding, if present, MUST be the + /// final coding. A list with `chunked` in any other position is rejected + /// rather than silently reordered (which would misrepresent the actual body + /// framing and hide caller bugs). TransferEncodingHeader.encodings(final List encodings) - : encodings = List.unmodifiable(_reorderEncodings(encodings)) { + : encodings = List.unmodifiable(encodings) { if (encodings.isEmpty) { throw ArgumentError.value(encodings, 'encodings', 'cannot be empty'); } + final chunkedIndex = encodings.indexWhere( + (final e) => e.name == TransferEncoding.chunked.name, + ); + if (chunkedIndex >= 0 && chunkedIndex != encodings.length - 1) { + throw const FormatException( + 'chunked transfer-coding must be the final coding (RFC 9112 6.1)', + ); + } } /// Parses the Transfer-Encoding header value and returns a [TransferEncodingHeader] instance. @@ -32,7 +45,18 @@ final class TransferEncodingHeader { throw const FormatException('Value cannot be empty'); } - final encodings = splitValues.map(TransferEncoding.parse).toList(); + // Deduplicate by canonical (case-insensitive) coding name, since + // splitTrimAndFilterUnique only removes exact-string duplicates. Without + // this, `chunked, CHUNKED` would keep both and then fail the + // chunked-must-be-last check even though they are the same coding. + final encodings = []; + final seen = {}; + for (final raw in splitValues) { + final encoding = TransferEncoding.parse(raw); + if (seen.add(encoding.name)) { + encodings.add(encoding); + } + } return TransferEncodingHeader.encodings(encodings); } @@ -58,35 +82,6 @@ final class TransferEncodingHeader { String toString() { return 'TransferEncodingHeader(encodings: $encodings)'; } - - /// Ensures that the 'chunked' transfer encoding is always the last in the list. - /// - /// According to the HTTP/1.1 specification (RFC 9112), the 'chunked' transfer - /// encoding must be the final encoding applied to the response body. This is - /// because 'chunked' signals the end of the response message, and any - /// encoding after 'chunked' would cause ambiguity or violate the standard. - /// - /// Example of valid ordering: - /// Transfer-Encoding: gzip, chunked - /// - /// Example of invalid ordering: - /// Transfer-Encoding: chunked, gzip - /// - /// This function reorders the encodings to comply with the standard and - /// ensures compatibility with HTTP clients and intermediaries. - static List _reorderEncodings( - final List encodings, - ) { - final TransferEncoding? chunked = encodings.firstWhereOrNull( - (final e) => e.name == TransferEncoding.chunked.name, - ); - if (chunked == null) return encodings; - - final reordered = List.from(encodings); - reordered.removeWhere((final e) => e.name == TransferEncoding.chunked.name); - reordered.add(chunked); - return reordered; - } } /// A class representing valid transfer encodings. @@ -111,13 +106,15 @@ class TransferEncoding { static const gzip = TransferEncoding._(_gzip); /// Parses a [name] and returns the corresponding [TransferEncoding] instance. - /// If the name does not match any predefined encodings, it returns a custom instance. + /// + /// Transfer-codings are case-insensitive (RFC 9112 7), so the name is + /// matched case-insensitively against the registered codings. factory TransferEncoding.parse(final String name) { final trimmed = name.trim(); if (trimmed.isEmpty) { throw const FormatException('Name cannot be empty'); } - switch (trimmed) { + switch (trimmed.toLowerCase()) { case _identity: return identity; case _chunked: diff --git a/packages/relic_core/lib/src/headers/typed/headers/upgrade_header.dart b/packages/relic_core/lib/src/headers/typed/headers/upgrade_header.dart index 06c25780..f96b2278 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/upgrade_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/upgrade_header.dart @@ -60,16 +60,27 @@ final class UpgradeHeader { } } -/// A class representing a single protocol in the Upgrade header. +/// A single protocol entry in an `Upgrade` header. +/// +/// protocol = protocol-name ["/" protocol-version] +/// protocol-name = token +/// protocol-version = token +/// +/// `protocol-version` is therefore an opaque token (e.g. `13` for WebSocket, +/// `2` for HTTP/2, `6.9` for IRC); it is not a number. Storing it as a string +/// preserves whatever the peer sent and avoids `HTTP/2` and `HTTP/2.0` +/// collapsing into the same value on the wire. class UpgradeProtocol { - /// The name of the protocol. + /// The name of the protocol (a token, e.g. `HTTP`, `WebSocket`). final String protocol; - /// The version of the protocol. - final double? version; + /// The version of the protocol (a token), or `null` when absent. + final String? version; /// Constructs an [UpgradeProtocol] instance with the specified name and version. - UpgradeProtocol({required this.protocol, this.version}); + UpgradeProtocol({required final String protocol, final String? version}) + : protocol = Token.validate(protocol), + version = version == null ? null : Token.validate(version); /// Parses a protocol string and returns an [UpgradeProtocol] instance. factory UpgradeProtocol.parse(final String value) { @@ -78,31 +89,26 @@ class UpgradeProtocol { throw const FormatException('Protocol cannot be empty'); } - final split = trimmed.split('/'); - if (split.length == 1) { - return UpgradeProtocol(protocol: split[0]); + final slash = trimmed.indexOf('/'); + if (slash < 0) { + return UpgradeProtocol(protocol: trimmed); } - final protocol = split[0]; + final protocol = trimmed.substring(0, slash); if (protocol.isEmpty) { throw const FormatException('Protocol cannot be empty'); } - final version = split[1]; + final version = trimmed.substring(slash + 1); if (version.isEmpty) { throw const FormatException('Version cannot be empty'); } - final parsedVersion = double.tryParse(version); - if (parsedVersion == null) { - throw const FormatException('Invalid version'); - } - - return UpgradeProtocol(protocol: protocol, version: parsedVersion); + return UpgradeProtocol(protocol: protocol, version: version); } /// Converts the [UpgradeProtocol] instance into a string representation. - String _encode() => '$protocol${version != null ? '/$version' : ''}'; + String _encode() => version != null ? '$protocol/$version' : protocol; @override bool operator ==(final Object other) => diff --git a/packages/relic_core/lib/src/headers/typed/headers/util/cookie_util.dart b/packages/relic_core/lib/src/headers/typed/headers/util/cookie_util.dart index 7eb185fb..9e0545c9 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/util/cookie_util.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/util/cookie_util.dart @@ -79,5 +79,26 @@ String validateCookieValue(final String value) { } } - return Uri.decodeComponent(value); + // Cookie values are an opaque octet string per RFC 6265; servers MUST NOT + // percent-decode them. Any URL encoding is an application-level convention + // and should be applied/reversed by the application, not the parser. + return value; +} + +/// Validates a Set-Cookie `Path` (or `Domain` path-style) attribute value and +/// returns it unchanged. +/// +/// Per RFC 6265 5.2.4 a path-value is `*av-octet` where `av-octet` is any +/// CHAR except CTLs or `;`. Rejecting control characters here prevents +/// header injection (CR/LF) when a path is built from untrusted input and +/// later serialized. +String validateCookiePath(final String path) { + for (int i = 0; i < path.length; i++) { + final int codeUnit = path.codeUnitAt(i); + // CTLs (0x00-0x1F, 0x7F) and ';' (0x3B) are not allowed in a path-value. + if (codeUnit <= 0x1F || codeUnit == 0x7F || codeUnit == 0x3B) { + throw const FormatException('Invalid cookie path'); + } + } + return path; } diff --git a/packages/relic_core/lib/src/headers/typed/headers/util/qvalue.dart b/packages/relic_core/lib/src/headers/typed/headers/util/qvalue.dart new file mode 100644 index 00000000..cdfca13f --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/headers/util/qvalue.dart @@ -0,0 +1,23 @@ +/// Formats an HTTP quality value (`qvalue`, RFC 9110 12.4.2) with at most 3 +/// fractional digits, truncating toward zero so a value just under 1.0 is not +/// rounded up to `1` (which would change the advertised preference). +/// +/// Trailing zeros are stripped (`0.500` -> `0.5`), and the bounds render as +/// `0` and `1`. +String formatQValue(final double q) { + if (q <= 0) return '0'; + if (q >= 1) return '1'; + // Truncate toward zero to 3 fractional digits. Computing the millis with + // floor (not toStringAsFixed, which rounds) keeps e.g. 0.99996 as 0.999 + // rather than rounding it up to 1. The tiny epsilon absorbs binary-float + // error so an exact value like 0.001 does not floor to 0. + final millis = (q * 1000 + 1e-9).floor(); + if (millis <= 0) return '0'; + if (millis >= 1000) return '1'; + var s = '0.${millis.toString().padLeft(3, '0')}'; + while (s.endsWith('0')) { + s = s.substring(0, s.length - 1); + } + if (s.endsWith('.')) s = s.substring(0, s.length - 1); + return s; +} diff --git a/packages/relic_core/lib/src/headers/typed/headers/util/report_to.dart b/packages/relic_core/lib/src/headers/typed/headers/util/report_to.dart new file mode 100644 index 00000000..6a59e624 --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/headers/util/report_to.dart @@ -0,0 +1,48 @@ +import '../../primitives/header_scanner.dart'; + +/// Extracts the `report-to` parameter value from already-split (top-level, +/// quote-aware) `;` parameter segments (e.g. for COEP/COOP). Returns `null` if +/// absent. A quoted value is decoded as an RFC 9110 `quoted-string`, with its +/// `quoted-pair` escapes removed. The parameter name is matched +/// case-insensitively. +String? parseReportToParam(final Iterable params) { + for (final raw in params) { + final p = raw.trim(); + final eq = p.indexOf('='); + if (eq < 0) continue; + if (p.substring(0, eq).trim().toLowerCase() != 'report-to') continue; + final rest = p.substring(eq + 1).trim(); + if (!rest.startsWith('"')) return rest; + // Decode through the canonical reader so quoted-pair escapes are handled + // exactly as elsewhere, rather than a divergent hand-rolled unescape. + final scanner = HeaderScanner(rest); + final value = scanner.readQuotedString(); + scanner.skipOws(); + if (!scanner.atEnd) { + throw FormatException( + 'unexpected characters after report-to value', + rest, + ); + } + return value; + } + return null; +} + +/// Renders a `report-to` parameter as `report-to="..."`, escaping interior +/// `"` and `\` as `quoted-pair` so the value round-trips through +/// [parseReportToParam]. +String encodeReportToParam(final String value) { + // Reject control characters (CR/LF in particular) rather than emitting them + // verbatim inside the header, so untrusted input cannot split it. + for (var i = 0; i < value.length; i++) { + final c = value.codeUnitAt(i); + if (c <= 0x1F || c == 0x7F) { + throw const FormatException( + 'report-to value must not contain control characters', + ); + } + } + final escaped = value.replaceAll(r'\', r'\\').replaceAll('"', r'\"'); + return 'report-to="$escaped"'; +} diff --git a/packages/relic_core/lib/src/headers/typed/headers/vary_header.dart b/packages/relic_core/lib/src/headers/typed/headers/vary_header.dart index 34e9189f..950c1f6f 100644 --- a/packages/relic_core/lib/src/headers/typed/headers/vary_header.dart +++ b/packages/relic_core/lib/src/headers/typed/headers/vary_header.dart @@ -19,7 +19,13 @@ final class VaryHeader { final bool isWildcard; /// Constructs an instance allowing specific headers to vary. - VaryHeader.headers({required this.fields}) : isWildcard = false { + /// + /// Field names are HTTP field names, which are case-insensitive (RFC 9110 + /// 5.1), so they are canonicalized to lowercase. This makes equality, + /// hashing, and membership checks case-insensitive. + VaryHeader.headers({required final Iterable fields}) + : fields = List.unmodifiable(fields.map((final f) => f.toLowerCase())), + isWildcard = false { if (fields.isEmpty) { throw ArgumentError.value(fields, 'fields', 'cannot be empty'); } diff --git a/packages/relic_core/lib/src/headers/typed/primitives/delta_seconds.dart b/packages/relic_core/lib/src/headers/typed/primitives/delta_seconds.dart new file mode 100644 index 00000000..2b515a56 --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/primitives/delta_seconds.dart @@ -0,0 +1,64 @@ +/// A non-negative integer number of seconds, used by HTTP headers that carry +/// `delta-seconds` values: `Max-Age` (RFC 9111 5.2.2.1, RFC 6265 5.2.2), +/// `Retry-After` seconds form (RFC 9110 10.2.3), HSTS `max-age` (RFC 6797 6.1), +/// and the second form of `Retry-After`. +/// +/// delta-seconds = 1*DIGIT +/// +/// The grammar forbids leading sign characters and any non-digit. Zero is +/// valid (often used to mean "expire immediately" or "clear state now"). +/// +/// Per RFC 9111 1.2.2, a value larger than the greatest integer the recipient +/// can represent is clamped to a large finite value rather than overflowing. +/// [DeltaSeconds.parse] clamps to [maxValue] for that reason, which also keeps +/// behaviour identical on the native (64-bit int) and web (53-bit double) +/// platforms. +extension type const DeltaSeconds._(int seconds) { + /// The clamp ceiling used by [parse] for over-large values: 2^31 seconds + /// (~68 years), the value RFC 9111 1.2.2 suggests as a representable cap. + static const int maxValue = 2147483648; + + /// Creates a [DeltaSeconds] from [seconds], throwing [FormatException] if + /// the value is negative. + factory DeltaSeconds(final int seconds) { + if (seconds < 0) { + throw FormatException( + 'delta-seconds must be non-negative', + seconds.toString(), + ); + } + return DeltaSeconds._(seconds); + } + + /// Parses [source] as `1*DIGIT` per RFC 9110 5.6.1. + /// + /// Throws [FormatException] if [source] is empty, contains non-DIGIT + /// characters, or carries a leading sign / whitespace. A value too large to + /// represent is clamped to [maxValue] (RFC 9111 1.2.2) rather than + /// overflowing or losing precision. + factory DeltaSeconds.parse(final String source) { + if (source.isEmpty) { + throw const FormatException('delta-seconds cannot be empty'); + } + for (var i = 0; i < source.length; i++) { + final c = source.codeUnitAt(i); + if (c < 0x30 || c > 0x39) { + throw FormatException( + 'delta-seconds must be 1*DIGIT (RFC 9110 5.6.1)', + source, + i, + ); + } + } + // int.tryParse returns null on native when the value exceeds the 64-bit + // range; on web it may round. Either way, clamp to maxValue. + final parsed = int.tryParse(source); + if (parsed == null || parsed > maxValue) { + return const DeltaSeconds._(maxValue); + } + return DeltaSeconds._(parsed); + } + + /// The wire representation: the decimal digits of [seconds]. + String encode() => seconds.toString(); +} diff --git a/packages/relic_core/lib/src/headers/typed/primitives/etag_value.dart b/packages/relic_core/lib/src/headers/typed/primitives/etag_value.dart new file mode 100644 index 00000000..ec37a3e8 --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/primitives/etag_value.dart @@ -0,0 +1,102 @@ +import 'package:meta/meta.dart'; + +/// An HTTP entity-tag per [RFC 9110 section 8.8.3][rfc-etag]. +/// +/// entity-tag = [ weak ] opaque-tag +/// weak = %s"W/" ; case-sensitive +/// opaque-tag = DQUOTE *etagc DQUOTE +/// etagc = %x21 / %x23-7E / obs-text +/// ; VCHAR minus DQUOTE, plus obs-text +/// +/// Used by `ETag`, `If-Match`, `If-None-Match`, and `If-Range`. +/// +/// [value] stores the unescaped opaque-tag contents (the bytes between the +/// two `"` characters). [isWeak] reflects the leading `W/` marker. +/// +/// [rfc-etag]: https://datatracker.ietf.org/doc/html/rfc9110#section-8.8.3 +@immutable +final class ETagValue { + /// The opaque-tag characters as they appear between the quotes on the + /// wire. Validated against `etagc` at construction. + final String value; + + /// True if this entity-tag carries the `W/` weak marker. + final bool isWeak; + + /// Creates an [ETagValue] from its parts. + /// + /// Throws [FormatException] if [value] contains any character outside the + /// `etagc` grammar (notably `"` and CTL characters). + ETagValue({required this.value, this.isWeak = false}) { + for (var i = 0; i < value.length; i++) { + if (!_isEtagc(value.codeUnitAt(i))) { + throw FormatException( + 'invalid character in ETag opaque-tag (RFC 9110 8.8.3)', + value, + i, + ); + } + } + } + + /// Parses [source] as `[ "W/" ] DQUOTE *etagc DQUOTE` per RFC 9110 8.8.3. + /// + /// The `W/` prefix is matched case-sensitively. The opaque-tag must be + /// quoted; surrounding whitespace is not allowed (callers should strip it + /// at the field level). + factory ETagValue.parse(final String source) { + var i = 0; + var weak = false; + if (source.length >= 2 && + source.codeUnitAt(0) == 0x57 && // W + source.codeUnitAt(1) == 0x2F) { + // / + weak = true; + i = 2; + } + if (i >= source.length || source.codeUnitAt(i) != _dquote) { + throw FormatException('expected opening DQUOTE', source, i); + } + if (source.length - i < 2 || + source.codeUnitAt(source.length - 1) != _dquote) { + throw FormatException('unterminated opaque-tag', source); + } + return ETagValue( + value: source.substring(i + 1, source.length - 1), + isWeak: weak, + ); + } + + /// The wire form: `[ "W/" ] DQUOTE value DQUOTE`. + String encode() => isWeak ? 'W/"$value"' : '"$value"'; + + /// Strong comparison per RFC 9110 8.8.3.2: equal iff both are strong and + /// have the same opaque-tag. Suitable for cache validation of + /// representations that must be byte-identical. + bool strongMatches(final ETagValue other) => + !isWeak && !other.isWeak && value == other.value; + + /// Weak comparison per RFC 9110 8.8.3.2: equal iff the opaque-tags match, + /// regardless of either tag's weak marker. + bool weakMatches(final ETagValue other) => value == other.value; + + @override + bool operator ==(final Object other) => + identical(this, other) || + (other is ETagValue && value == other.value && isWeak == other.isWeak); + + @override + int get hashCode => Object.hash(value, isWeak); + + @override + String toString() => encode(); +} + +const int _dquote = 0x22; + +bool _isEtagc(final int c) { + if (c == 0x21) return true; + if (c >= 0x23 && c <= 0x7E) return true; // VCHAR minus DQUOTE + if (c >= 0x80 && c <= 0xFF) return true; // obs-text + return false; +} diff --git a/packages/relic_core/lib/src/headers/typed/primitives/header_scanner.dart b/packages/relic_core/lib/src/headers/typed/primitives/header_scanner.dart new file mode 100644 index 00000000..4fb722d6 --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/primitives/header_scanner.dart @@ -0,0 +1,235 @@ +import 'token.dart'; + +/// A cursor-based scanner for HTTP header field values. +/// +/// Provides the lexer primitives every typed header parser needs: +/// +/// * RFC 9110 `OWS` skipping ([skipOws]). +/// * `token` reading ([readToken], [tryReadToken]). +/// * `quoted-string` reading with `quoted-pair` unescaping +/// ([readQuotedString], [tryReadQuotedString]). +/// * The canonical `token / quoted-string` alternation +/// ([readTokenOrQuotedString], [tryReadTokenOrQuotedString]). +/// * Quote-aware top-level scanning ([indexOfTopLevel], [splitTopLevel]) so +/// list and parameter separators inside a `quoted-string` are not treated +/// as structural delimiters. +/// +/// All methods throw [FormatException] on malformed input, reporting the +/// scanner's current [position] within [source]. +final class HeaderScanner { + /// The full source string being scanned. + final String source; + + int _pos = 0; + + /// Creates a scanner positioned at the start of [source]. + HeaderScanner(this.source); + + /// The current cursor position (0-based, inclusive). + int get position => _pos; + + /// Move the cursor. Must be within `[0, source.length]`. + set position(final int p) { + RangeError.checkValueInInterval(p, 0, source.length, 'position'); + _pos = p; + } + + /// True when the cursor has reached the end of [source]. + bool get atEnd => _pos >= source.length; + + /// Number of code units remaining after the cursor. + int get remaining => source.length - _pos; + + /// Returns the code unit at the cursor, or `-1` if at end. + int peek() => _pos < source.length ? source.codeUnitAt(_pos) : -1; + + /// Consumes [char] if it is at the cursor. Returns true if consumed. + bool tryConsume(final int char) { + if (peek() != char) return false; + _pos++; + return true; + } + + /// Consumes [char] at the cursor, otherwise throws [FormatException]. + void expect(final int char) { + if (!tryConsume(char)) { + throw _error("expected '${String.fromCharCode(char)}'"); + } + } + + /// Skips RFC 9110 `OWS = *( SP / HTAB )` at the cursor. + void skipOws() { + while (_pos < source.length) { + final c = source.codeUnitAt(_pos); + if (c != 0x20 && c != 0x09) break; + _pos++; + } + } + + /// Reads a `token` (RFC 9110 5.6.2) starting at the cursor and advances + /// past it. Returns the token characters, or `null` if no `tchar` is at + /// the cursor. + String? tryReadToken() { + final start = _pos; + while (_pos < source.length && Token.isTchar(source.codeUnitAt(_pos))) { + _pos++; + } + return _pos == start ? null : source.substring(start, _pos); + } + + /// Reads a `token`, throwing [FormatException] if no `tchar` is at the + /// cursor. + String readToken() => tryReadToken() ?? (throw _error('token expected')); + + /// Reads a `quoted-string` (RFC 9110 5.6.4) starting at the cursor and + /// returns its unescaped contents (interior `quoted-pair`s decoded). + /// + /// quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE + /// qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text + /// quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) + /// + /// Returns `null` (without advancing) if the cursor is not at `"`. + /// Throws [FormatException] on a malformed or unterminated quoted-string; + /// the cursor is rewound to the opening quote in that case. + String? tryReadQuotedString() { + if (peek() != _dquote) return null; + final start = _pos; + _pos++; // consume opening " + final buf = StringBuffer(); + while (_pos < source.length) { + final c = source.codeUnitAt(_pos); + if (c == _dquote) { + _pos++; + return buf.toString(); + } + if (c == _backslash) { + _pos++; + if (_pos >= source.length) { + _pos = start; + throw _error('unterminated quoted-pair', start); + } + final esc = source.codeUnitAt(_pos); + if (!_isQuotedPairTarget(esc)) { + _pos = start; + throw _error('invalid quoted-pair', start); + } + buf.writeCharCode(esc); + _pos++; + continue; + } + if (!_isQdtext(c)) { + _pos = start; + throw _error('invalid character in quoted-string', start); + } + buf.writeCharCode(c); + _pos++; + } + _pos = start; + throw _error('unterminated quoted-string', start); + } + + /// Reads a `quoted-string`, throwing [FormatException] if the cursor is + /// not at `"`. + String readQuotedString() => + tryReadQuotedString() ?? (throw _error('quoted-string expected')); + + /// Reads either a `quoted-string` (preferred when the cursor is at `"`) or + /// a `token`. This is the RFC 9110 `token / quoted-string` alternation + /// used pervasively for parameter values. + /// + /// Returns the unescaped value, or `null` if neither is at the cursor. + String? tryReadTokenOrQuotedString() { + if (peek() == _dquote) return tryReadQuotedString(); + return tryReadToken(); + } + + /// Like [tryReadTokenOrQuotedString] but throws if neither is present. + String readTokenOrQuotedString() => + tryReadTokenOrQuotedString() ?? + (throw _error('token or quoted-string expected')); + + /// Returns the offset of the next occurrence of [char] at the top level + /// (i.e. not inside a `quoted-string`), or `-1` if not found before the end + /// of [source]. Does not advance the cursor. + /// + /// Throws [FormatException] if a `quoted-string` encountered during the + /// scan is malformed; the cursor is left at its original position. + int indexOfTopLevel(final int char) { + final saved = _pos; + try { + while (_pos < source.length) { + final c = source.codeUnitAt(_pos); + if (c == char) return _pos; + if (c == _dquote) { + tryReadQuotedString(); + continue; + } + _pos++; + } + return -1; + } finally { + _pos = saved; + } + } + + /// Yields the top-level substrings of [source] (from the cursor to end) + /// separated by [separator], skipping over `quoted-string`s and trimming + /// surrounding `OWS` from each yielded element. + /// + /// An empty source yields no elements. A trailing or leading [separator] or + /// an empty element between two separators yields an empty string at that + /// position. Iteration advances the cursor; after the last element the + /// cursor is at [source].length. Throws [FormatException] if a + /// `quoted-string` is malformed. + Iterable splitTopLevel(final int separator) sync* { + if (atEnd) return; + while (true) { + skipOws(); + final elementStart = _pos; + final stop = indexOfTopLevel(separator); + if (stop < 0) { + yield _rtrimOws(source.substring(elementStart)); + _pos = source.length; + return; + } + yield _rtrimOws(source.substring(elementStart, stop)); + _pos = stop + 1; + } + } + + FormatException _error(final String message, [final int? offset]) => + FormatException(message, source, offset ?? _pos); +} + +const int _dquote = 0x22; +const int _backslash = 0x5C; + +bool _isQdtext(final int c) { + // qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text + if (c == 0x09) return true; + if (c == 0x20) return true; + if (c == 0x21) return true; + if (c >= 0x23 && c <= 0x5B) return true; + if (c >= 0x5D && c <= 0x7E) return true; + if (c >= 0x80 && c <= 0xFF) return true; // obs-text + return false; +} + +bool _isQuotedPairTarget(final int c) { + // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) + if (c == 0x09) return true; + if (c == 0x20) return true; + if (c >= 0x21 && c <= 0x7E) return true; // VCHAR + if (c >= 0x80 && c <= 0xFF) return true; // obs-text + return false; +} + +String _rtrimOws(final String s) { + var end = s.length; + while (end > 0) { + final c = s.codeUnitAt(end - 1); + if (c != 0x20 && c != 0x09) break; + end--; + } + return end == s.length ? s : s.substring(0, end); +} diff --git a/packages/relic_core/lib/src/headers/typed/primitives/host.dart b/packages/relic_core/lib/src/headers/typed/primitives/host.dart new file mode 100644 index 00000000..15ceecb8 --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/primitives/host.dart @@ -0,0 +1,271 @@ +import 'package:meta/meta.dart'; + +/// An HTTP host reference per RFC 3986 section 3.2.2, used by `Host` headers, +/// the `Domain` attribute of cookies, and the `host=`/`for=` values in +/// `Forwarded`. +/// +/// host = IP-literal / IPv4address / reg-name +/// IP-literal = "[" ( IPv6address / IPvFuture ) "]" +/// +/// On the wire an IPv6 [host] is bracketed. In a [Host] instance the brackets +/// are not stored on the [host] field; the wire form is produced by [encode], +/// which adds brackets when [host] contains `:` (IPv6 / IPvFuture). +/// +/// [port] is the optional explicit port (`uri-host [ ":" port ]`). An absent +/// port is represented by `null`; this is distinct from a default port (80, +/// 443, ...) which the [fromUri] factory takes care to preserve. +@immutable +final class Host { + /// The host literal without IPv6 brackets. For example: `example.com`, + /// `192.0.2.1`, `::1`. Case is preserved as supplied; equality is ASCII + /// case-insensitive. + final String host; + + /// The explicit port, or `null` when no port was supplied. + final int? port; + + /// Creates a [Host] from its parts. + /// + /// Throws [FormatException] if [host] is empty or contains URI brackets + /// (the brackets belong to the wire form only), or if [port] is outside + /// the 0-65535 range. + Host(this.host, [this.port]) { + if (host.isEmpty) { + throw const FormatException('host cannot be empty'); + } + if (host.codeUnits.contains(0x5B) || host.codeUnits.contains(0x5D)) { + throw FormatException( + 'host must not include URI brackets; pass the unbracketed value', + host, + ); + } + // Reject control characters, whitespace, and structural delimiters. This + // covers every host form (reg-name, IPv6, IPvFuture: none contain these) + // and, critically, blocks CR/LF injection when a host built from + // untrusted input is later serialized (e.g. into a cookie Domain). + for (var i = 0; i < host.length; i++) { + if (_isForbiddenHostChar(host.codeUnitAt(i))) { + throw FormatException('invalid character in host', host, i); + } + } + // A colon is only valid in an IP-literal (IPv6 / IPvFuture). Validating it + // here rejects a reg-name like `a:b`, which would otherwise be bracketed + // by [encode] to `[a:b]` and then fail to re-parse. + if (host.codeUnits.contains(0x3A)) { + _validateIpLiteral(host, host); + } + final p = port; + if (p != null && (p < 0 || p > 65535)) { + throw FormatException('port must be in 0-65535', p.toString()); + } + } + + /// Creates a [Host] from a [Uri], honoring `Uri.hasPort` so that an + /// implicit default port is left as `null` rather than coerced to 80/443. + factory Host.fromUri(final Uri uri) { + if (uri.host.isEmpty) { + throw FormatException('URI has no host', uri.toString()); + } + return Host(uri.host, uri.hasPort ? uri.port : null); + } + + /// Parses [source] as `uri-host [ ":" port ]`. + /// + /// IPv6 / IPvFuture hosts MUST be bracketed on the wire; unbracketed IPv6 + /// (e.g. `::1:80`) is ambiguous and rejected. + factory Host.parse(final String source) { + if (source.isEmpty) { + throw const FormatException('host cannot be empty'); + } + if (source.startsWith('[')) { + final close = source.indexOf(']'); + if (close < 0) { + throw FormatException('unterminated IP-literal', source); + } + final inner = source.substring(1, close); + if (inner.isEmpty) { + throw FormatException('empty IP-literal', source); + } + _validateIpLiteral(inner, source); + final tail = source.substring(close + 1); + if (tail.isEmpty) return Host(inner); + if (!tail.startsWith(':')) { + throw FormatException( + 'expected ":port" or end after IP-literal', + source, + close + 1, + ); + } + return Host(inner, _parsePort(tail.substring(1), source)); + } + final firstColon = source.indexOf(':'); + if (firstColon < 0) { + return Host(source); + } + if (source.indexOf(':', firstColon + 1) >= 0) { + throw FormatException( + 'unbracketed IPv6 host is ambiguous; use [ipv6]:port', + source, + ); + } + final hostPart = source.substring(0, firstColon); + return Host(hostPart, _parsePort(source.substring(firstColon + 1), source)); + } + + /// The wire form: `uri-host [ ":" port ]`, adding IPv6 brackets when [host] + /// contains a colon. + /// + /// A colon-less IPvFuture host (`vX.…`, essentially never seen in practice) + /// is not re-bracketed here, because it cannot be told apart from a + /// versioned reg-name such as `v2.example.com` by content alone. + String encode() { + final h = host.codeUnits.contains(0x3A) ? '[$host]' : host; + return port == null ? h : '$h:$port'; + } + + @override + bool operator ==(final Object other) => + identical(this, other) || + (other is Host && + _asciiCaseInsensitiveEquals(host, other.host) && + port == other.port); + + @override + int get hashCode => Object.hash(_asciiLower(host), port); + + @override + String toString() => encode(); +} + +/// Validates the contents of an `IP-literal` (the text inside `[...]`). +/// +/// Per RFC 3986 3.2.2 an IP-literal is `IPv6address` or `IPvFuture`. Without +/// this check `Host.parse('[zzz]')` would be accepted as a host named `zzz`. +void _validateIpLiteral(final String inner, final String source) { + if (inner[0] == 'v' || inner[0] == 'V') { + _validateIpvFuture(inner, source); + return; + } + try { + Uri.parseIPv6Address(inner); + } on FormatException { + throw FormatException('invalid IPv6 address in IP-literal', source); + } +} + +/// Validates `IPvFuture = "v" 1*HEXDIG "." 1*( unreserved / sub-delims / ":" )`. +void _validateIpvFuture(final String inner, final String source) { + final dot = inner.indexOf('.'); + // Need at least one HEXDIG between the leading 'v' and the '.', and at least + // one character after it. + if (dot < 2 || dot == inner.length - 1) { + throw FormatException('invalid IPvFuture literal', source); + } + for (var i = 1; i < dot; i++) { + if (!_isHexDigit(inner.codeUnitAt(i))) { + throw FormatException('invalid IPvFuture version', source); + } + } + for (var i = dot + 1; i < inner.length; i++) { + if (!_isIpvFutureTailChar(inner.codeUnitAt(i))) { + throw FormatException('invalid IPvFuture character', source); + } + } +} + +bool _isHexDigit(final int c) => + (c >= 0x30 && c <= 0x39) || + (c >= 0x41 && c <= 0x46) || + (c >= 0x61 && c <= 0x66); + +bool _isIpvFutureTailChar(final int c) { + // unreserved + if ((c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A)) { + return true; // ALPHA + } + if (c >= 0x30 && c <= 0x39) return true; // DIGIT + if (c == 0x2D || c == 0x2E || c == 0x5F || c == 0x7E) return true; // - . _ ~ + // sub-delims: ! $ & ' ( ) * + , ; = + switch (c) { + case 0x21: + case 0x24: + case 0x26: + case 0x27: + case 0x28: + case 0x29: + case 0x2A: + case 0x2B: + case 0x2C: + case 0x3B: + case 0x3D: + case 0x3A: // ":" + return true; + } + return false; +} + +bool _isForbiddenHostChar(final int c) { + if (c <= 0x20 || c == 0x7F) return true; // CTLs + SP + DEL + switch (c) { + case 0x22: // " + case 0x23: // # + case 0x2F: // / + case 0x3C: // < + case 0x3E: // > + case 0x3F: // ? + case 0x40: // @ + case 0x5C: // backslash + case 0x5E: // ^ + case 0x60: // backtick + case 0x7B: // { + case 0x7C: // | + case 0x7D: // } + return true; + } + return false; +} + +int _parsePort(final String s, final String source) { + if (s.isEmpty) { + throw FormatException('port cannot be empty', source); + } + if (s.length > 5) { + throw FormatException('port out of range', source); + } + var v = 0; + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c < 0x30 || c > 0x39) { + throw FormatException('port must contain only digits', source); + } + v = v * 10 + (c - 0x30); + } + if (v > 65535) { + throw FormatException('port must be in 0-65535', source); + } + return v; +} + +bool _asciiCaseInsensitiveEquals(final String a, final String b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (_asciiFold(a.codeUnitAt(i)) != _asciiFold(b.codeUnitAt(i))) { + return false; + } + } + return true; +} + +String _asciiLower(final String s) { + final buf = StringBuffer(); + for (var i = 0; i < s.length; i++) { + buf.writeCharCode(_asciiFold(s.codeUnitAt(i))); + } + return buf.toString(); +} + +int _asciiFold(final int c) { + if (c >= 0x41 && c <= 0x5A) return c + 0x20; + return c; +} diff --git a/packages/relic_core/lib/src/headers/typed/primitives/language_tag.dart b/packages/relic_core/lib/src/headers/typed/primitives/language_tag.dart new file mode 100644 index 00000000..16fa10fd --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/primitives/language_tag.dart @@ -0,0 +1,292 @@ +import 'package:meta/meta.dart'; + +/// A BCP 47 language tag per [RFC 5646][rfc5646], used by `Content-Language` +/// (RFC 9110 8.5) and `Accept-Language` (RFC 9110 12.5.4). +/// +/// Language-Tag = langtag / privateuse / grandfathered +/// +/// BCP 47 tags are matched case-insensitively, but conventional output uses +/// title casing for script subtags, uppercase for ALPHA region subtags, and +/// lowercase for everything else. This class normalizes the case of each +/// subtag in [encode] without reordering them, so a correctly-ordered tag +/// such as `EN-Latn-us` round-trips as `en-Latn-US`. Subtags supplied in the +/// wrong order (e.g. region before script) are rejected, not reordered. +/// +/// Equality is case-insensitive (operates on the canonical form). +/// +/// [rfc5646]: https://datatracker.ietf.org/doc/html/rfc5646 +@immutable +final class LanguageTag { + /// The canonical-cased subtags as they appear separated by `-` on the wire. + final List subtags; + + const LanguageTag._(this.subtags); + + /// Parses [source] as a BCP 47 language tag. + /// + /// Throws [FormatException] when [source] is empty, contains an empty + /// subtag, or does not match the grammar above. + factory LanguageTag.parse(final String source) { + if (source.isEmpty) { + throw const FormatException('language-tag cannot be empty'); + } + + final raw = source.split('-'); + for (final s in raw) { + if (s.isEmpty) { + throw FormatException('empty subtag', source); + } + if (s.length > 8) { + throw FormatException('subtag too long', s); + } + for (var i = 0; i < s.length; i++) { + if (!_isAlphaNum(s.codeUnitAt(i))) { + throw FormatException('subtag must be alphanumeric', s); + } + } + } + + final lowerSource = source.toLowerCase(); + if (_irregularGrandfathered.contains(lowerSource)) { + return LanguageTag._(List.unmodifiable(lowerSource.split('-'))); + } + + if (_lower(raw[0]) == 'x') { + if (raw.length < 2) { + throw FormatException('private-use needs subtags', source); + } + for (var i = 1; i < raw.length; i++) { + if (raw[i].length > 8) { + throw FormatException('private-use subtag too long', raw[i]); + } + } + return LanguageTag._(List.unmodifiable(raw.map(_lower))); + } + + final canonical = []; + var i = 0; + + final lang = raw[i]; + if (!_isAllAlpha(lang) || lang.length < 2 || lang.length > 8) { + throw FormatException('language subtag must be 2-8 ALPHA', lang); + } + canonical.add(_lower(lang)); + i++; + + if (lang.length <= 3) { + var extlangs = 0; + while (i < raw.length && + extlangs < 3 && + raw[i].length == 3 && + _isAllAlpha(raw[i])) { + canonical.add(_lower(raw[i])); + i++; + extlangs++; + } + } + + if (i < raw.length && raw[i].length == 4 && _isAllAlpha(raw[i])) { + canonical.add(_titleCase(raw[i])); + i++; + } + + if (i < raw.length) { + final r = raw[i]; + if (r.length == 2 && _isAllAlpha(r)) { + canonical.add(_upper(r)); + i++; + } else if (r.length == 3 && _isAllDigit(r)) { + canonical.add(r); + i++; + } + } + + // BCP 47 (RFC 5646 2.2.5): the same variant subtag MUST NOT appear twice. + final seenVariants = {}; + while (i < raw.length && _isVariant(raw[i])) { + final variant = _lower(raw[i]); + if (!seenVariants.add(variant)) { + throw FormatException('duplicate variant subtag "$variant"', source); + } + canonical.add(variant); + i++; + } + + // BCP 47 (RFC 5646 2.2.6): a given extension singleton MUST NOT repeat. + final seenSingletons = {}; + while (i < raw.length && + raw[i].length == 1 && + _isExtensionSingleton(raw[i].codeUnitAt(0))) { + final singleton = _lower(raw[i]); + if (!seenSingletons.add(singleton)) { + throw FormatException( + 'duplicate extension singleton "$singleton"', + source, + ); + } + canonical.add(singleton); + i++; + var subCount = 0; + while (i < raw.length && + raw[i].length >= 2 && + raw[i].length <= 8 && + _isAllAlphaNum(raw[i])) { + canonical.add(_lower(raw[i])); + i++; + subCount++; + } + if (subCount == 0) { + throw FormatException( + 'extension singleton needs at least one subtag', + source, + ); + } + } + + if (i < raw.length && _lower(raw[i]) == 'x') { + canonical.add('x'); + i++; + if (i >= raw.length) { + throw FormatException('private-use needs subtags', source); + } + while (i < raw.length) { + canonical.add(_lower(raw[i])); + i++; + } + } + + if (i < raw.length) { + throw FormatException( + 'unrecognized subtag "${raw[i]}" at position $i', + source, + ); + } + + return LanguageTag._(List.unmodifiable(canonical)); + } + + /// The wire form of this tag, joined with `-`, in canonical case. + String encode() => subtags.join('-'); + + @override + bool operator ==(final Object other) => + identical(this, other) || + (other is LanguageTag && _listEqual(subtags, other.subtags)); + + @override + int get hashCode => Object.hashAll(subtags); + + @override + String toString() => encode(); +} + +bool _isAlpha(final int c) => + (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A); + +bool _isDigit(final int c) => c >= 0x30 && c <= 0x39; + +bool _isAlphaNum(final int c) => _isAlpha(c) || _isDigit(c); + +bool _isAllAlpha(final String s) { + for (var i = 0; i < s.length; i++) { + if (!_isAlpha(s.codeUnitAt(i))) return false; + } + return true; +} + +bool _isAllDigit(final String s) { + for (var i = 0; i < s.length; i++) { + if (!_isDigit(s.codeUnitAt(i))) return false; + } + return true; +} + +bool _isAllAlphaNum(final String s) { + for (var i = 0; i < s.length; i++) { + if (!_isAlphaNum(s.codeUnitAt(i))) return false; + } + return true; +} + +bool _isVariant(final String s) { + if (s.length == 4 && _isDigit(s.codeUnitAt(0))) { + for (var i = 1; i < s.length; i++) { + if (!_isAlphaNum(s.codeUnitAt(i))) return false; + } + return true; + } + if (s.length >= 5 && s.length <= 8) return _isAllAlphaNum(s); + return false; +} + +bool _isExtensionSingleton(final int c) { + // singleton = DIGIT / A-W / Y-Z / a-w / y-z (excluding 'x'/'X', which is + // reserved for private-use). + if (_isDigit(c)) return true; + if (c >= 0x41 && c <= 0x57) return true; + if (c == 0x59 || c == 0x5A) return true; + if (c >= 0x61 && c <= 0x77) return true; + if (c == 0x79 || c == 0x7A) return true; + return false; +} + +String _lower(final String s) { + final buf = StringBuffer(); + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + buf.writeCharCode((c >= 0x41 && c <= 0x5A) ? c + 0x20 : c); + } + return buf.toString(); +} + +String _upper(final String s) { + final buf = StringBuffer(); + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + buf.writeCharCode((c >= 0x61 && c <= 0x7A) ? c - 0x20 : c); + } + return buf.toString(); +} + +String _titleCase(final String s) { + if (s.isEmpty) return s; + final buf = StringBuffer(); + final c0 = s.codeUnitAt(0); + buf.writeCharCode((c0 >= 0x61 && c0 <= 0x7A) ? c0 - 0x20 : c0); + for (var i = 1; i < s.length; i++) { + final c = s.codeUnitAt(i); + buf.writeCharCode((c >= 0x41 && c <= 0x5A) ? c + 0x20 : c); + } + return buf.toString(); +} + +bool _listEqual(final List a, final List b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; +} + +/// Irregular grandfathered tags from RFC 5646 Appendix A. These do not +/// otherwise match the `langtag` grammar but remain valid BCP 47 tags. +const Set _irregularGrandfathered = { + 'en-gb-oed', + 'i-ami', + 'i-bnn', + 'i-default', + 'i-enochian', + 'i-hak', + 'i-klingon', + 'i-lux', + 'i-mingo', + 'i-navajo', + 'i-pwn', + 'i-tao', + 'i-tay', + 'i-tsu', + 'sgn-be-fr', + 'sgn-be-nl', + 'sgn-ch-de', +}; diff --git a/packages/relic_core/lib/src/headers/typed/primitives/origin.dart b/packages/relic_core/lib/src/headers/typed/primitives/origin.dart new file mode 100644 index 00000000..5d094655 --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/primitives/origin.dart @@ -0,0 +1,166 @@ +import 'package:meta/meta.dart'; + +import 'host.dart'; + +/// A Web origin, as defined by the WHATWG Fetch Standard and RFC 6454. +/// +/// An origin is either: +/// +/// * [OpaqueOrigin] - the literal byte string `null`, used for opaque origins +/// such as sandboxed `iframe`s, `data:` URLs, and `file:` resources. This +/// is a distinct case from "no origin"; the wire form is the bare token +/// `null` (without quotes). +/// * [TupleOrigin] - a `scheme://host[:port]` triple. The serialized form has +/// no trailing slash, no path, no query, and no fragment. +/// +/// `Access-Control-Allow-Origin` is the canonical example of a header whose +/// value is exactly one [Origin] (or the wildcard `*`, which is *not* an +/// origin and lives outside this type). +sealed class Origin { + const Origin(); + + /// Parses [source] as an origin. + /// + /// Accepts: + /// * The literal `null` (returns the [OpaqueOrigin] sentinel). + /// * `scheme://host[:port]` (returns a [TupleOrigin]). + /// + /// Rejects any input with a path, query, or fragment. + factory Origin.parse(final String source) { + if (source == 'null') return OpaqueOrigin.instance; + + final schemeEnd = source.indexOf('://'); + if (schemeEnd < 0) { + throw FormatException('expected "scheme://host[:port]"', source); + } + final scheme = source.substring(0, schemeEnd); + final rest = source.substring(schemeEnd + 3); + if (rest.isEmpty) { + throw FormatException('missing host', source); + } + + var inBracket = false; + for (var i = 0; i < rest.length; i++) { + final c = rest.codeUnitAt(i); + if (c == 0x5B) { + inBracket = true; + continue; + } + if (c == 0x5D) { + inBracket = false; + continue; + } + if (inBracket) continue; + // A serialized origin is scheme "://" host [ ":" port ] only: no + // path/query/fragment, no userinfo, and no controls or whitespace. + if (c == 0x2F || c == 0x3F || c == 0x23) { + throw FormatException( + 'origin must not include path, query, or fragment', + source, + schemeEnd + 3 + i, + ); + } + if (c == 0x40) { + throw FormatException( + 'origin must not include userinfo', + source, + schemeEnd + 3 + i, + ); + } + if (c <= 0x20 || c == 0x7F) { + throw FormatException( + 'origin must not contain control characters or whitespace', + source, + schemeEnd + 3 + i, + ); + } + } + + return TupleOrigin(scheme: scheme, host: Host.parse(rest)); + } + + /// The wire form of this origin. + String encode(); +} + +/// The opaque origin sentinel, serialized as `null`. +final class OpaqueOrigin extends Origin { + const OpaqueOrigin._(); + + /// The canonical instance. Use this rather than allocating a new one each + /// time. + static const OpaqueOrigin instance = OpaqueOrigin._(); + + @override + String encode() => 'null'; + + @override + bool operator ==(final Object other) => other is OpaqueOrigin; + + @override + // A fixed non-zero hash for the single opaque-origin value. Non-zero so it + // does not collide with the common `null`/0 bucket (e.g. an Allow-Origin + // wildcard whose origin is null). + int get hashCode => 0x09A9; + + @override + String toString() => 'null'; +} + +/// A `scheme://host[:port]` origin tuple per RFC 6454. +@immutable +final class TupleOrigin extends Origin { + /// The URI scheme, validated against the RFC 3986 grammar and stored in + /// ASCII-lowercase form (schemes are case-insensitive). + final String scheme; + + /// The host component. + final Host host; + + /// Creates a [TupleOrigin] from its parts. + /// + /// Throws [FormatException] if [scheme] is empty or does not match + /// `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` (RFC 3986 3.1). + TupleOrigin({required final String scheme, required this.host}) + : scheme = _validateScheme(scheme); + + @override + String encode() => '$scheme://${host.encode()}'; + + @override + bool operator ==(final Object other) => + identical(this, other) || + (other is TupleOrigin && scheme == other.scheme && host == other.host); + + @override + int get hashCode => Object.hash(scheme, host); + + @override + String toString() => encode(); +} + +String _validateScheme(final String s) { + if (s.isEmpty) { + throw const FormatException('scheme cannot be empty'); + } + final c0 = s.codeUnitAt(0); + if (!_isAlpha(c0)) { + throw FormatException('scheme must start with ALPHA', s, 0); + } + for (var i = 1; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (!_isAlpha(c) && + !_isDigit(c) && + c != 0x2B && // + + c != 0x2D && // - + c != 0x2E) { + // . + throw FormatException('invalid character in scheme', s, i); + } + } + return s.toLowerCase(); +} + +bool _isAlpha(final int c) => + (c >= 0x41 && c <= 0x5A) || (c >= 0x61 && c <= 0x7A); +bool _isDigit(final int c) => c >= 0x30 && c <= 0x39; diff --git a/packages/relic_core/lib/src/headers/typed/primitives/parameter_value.dart b/packages/relic_core/lib/src/headers/typed/primitives/parameter_value.dart new file mode 100644 index 00000000..a735f4d4 --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/primitives/parameter_value.dart @@ -0,0 +1,82 @@ +import 'package:meta/meta.dart'; + +import 'header_scanner.dart'; +import 'token.dart'; + +/// A parameter value for the `token / quoted-string` alternation used +/// pervasively in HTTP headers (RFC 9110 5.6.6: `parameters`). +/// +/// On the wire a parameter value is either a bare `token` (no quoting, +/// limited to `tchar`) or a `quoted-string` (DQUOTE-wrapped, with `\` escapes +/// for interior `"` and `\`). +/// +/// Internally a [ParameterValue] always carries the unescaped value. On +/// [encode] the bare-token form is chosen when [value] is a valid token, and +/// the `quoted-string` form is chosen otherwise, with the necessary +/// `quoted-pair` escapes applied automatically. +@immutable +final class ParameterValue { + /// The unescaped parameter value bytes (as a Dart [String]). + final String value; + + /// Creates a [ParameterValue]. + /// + /// Throws [FormatException] if [value] contains a character that cannot + /// appear inside either a `token` or a `quoted-string` (i.e. CTL bytes + /// other than HTAB, or code units beyond `0xFF`). + ParameterValue(this.value) { + for (var i = 0; i < value.length; i++) { + if (!_isLegalInQuotedString(value.codeUnitAt(i))) { + throw FormatException( + 'character not representable as token or quoted-string body', + value, + i, + ); + } + } + } + + /// Reads a parameter value from [scanner]'s current position. + /// + /// Equivalent to [HeaderScanner.readTokenOrQuotedString] but returns a + /// [ParameterValue] wrapper. + factory ParameterValue.read(final HeaderScanner scanner) { + return ParameterValue(scanner.readTokenOrQuotedString()); + } + + /// Returns the wire form: bare token when [value] is a valid token, + /// otherwise a `quoted-string` with `"` and `\` escaped. + String encode() { + if (Token.isValid(value)) return value; + return _quote(value); + } + + @override + bool operator ==(final Object other) => + identical(this, other) || + (other is ParameterValue && value == other.value); + + @override + int get hashCode => value.hashCode; + + @override + String toString() => encode(); +} + +String _quote(final String s) { + final buf = StringBuffer()..writeCharCode(0x22); + for (var i = 0; i < s.length; i++) { + final c = s.codeUnitAt(i); + if (c == 0x22 || c == 0x5C) buf.writeCharCode(0x5C); + buf.writeCharCode(c); + } + buf.writeCharCode(0x22); + return buf.toString(); +} + +bool _isLegalInQuotedString(final int c) { + if (c == 0x09) return true; // HTAB + if (c >= 0x20 && c <= 0x7E) return true; // SP + VCHAR + if (c >= 0x80 && c <= 0xFF) return true; // obs-text + return false; +} diff --git a/packages/relic_core/lib/src/headers/typed/primitives/token.dart b/packages/relic_core/lib/src/headers/typed/primitives/token.dart new file mode 100644 index 00000000..f3ad21e1 --- /dev/null +++ b/packages/relic_core/lib/src/headers/typed/primitives/token.dart @@ -0,0 +1,144 @@ +import 'package:meta/meta.dart'; + +/// An HTTP `token` value per [RFC 9110 section 5.6.2][rfc-token]. +/// +/// token = 1*tchar +/// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" +/// / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA +/// +/// Tokens are ASCII case-insensitive when compared as wire values +/// ([RFC 9110 section 5.6.2][rfc-token] and [section 11.1][rfc-scheme]). +/// +/// This interface lets closed-by-spec enums (e.g. cache directive names) and +/// open-token-valued types (e.g. content codings) share the same parser and +/// encoder primitives. Concrete implementations supply a [value] string whose +/// characters MUST satisfy [Token.isValid]. +/// +/// Implementations may use either identity equality (natural for Dart enum +/// values) or ASCII case-insensitive value equality (suitable for free-form +/// implementors such as [TokenValue]). To compare two [Token]s by their wire +/// value regardless of how their [operator ==] is defined, use [Token.equals]. +/// +/// [rfc-token]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2 +/// [rfc-scheme]: https://datatracker.ietf.org/doc/html/rfc9110#section-11.1 +abstract interface class Token { + /// The token characters as they appear on the wire. + String get value; + + /// True if [s] is a syntactically valid token: non-empty and composed only + /// of `tchar` characters. + static bool isValid(final String s) { + if (s.isEmpty) return false; + for (var i = 0; i < s.length; i++) { + if (!isTchar(s.codeUnitAt(i))) return false; + } + return true; + } + + /// True if [c] is a single `tchar` code unit per RFC 9110 5.6.2. + static bool isTchar(final int c) => _isTchar(c); + + /// Returns [s] unchanged if it is a valid token, otherwise throws + /// [FormatException]. + static String validate(final String s) { + if (!isValid(s)) { + throw FormatException('Not a valid HTTP token (RFC 9110 5.6.2)', s); + } + return s; + } + + /// ASCII case-insensitive equality of two token wire values. + /// + /// Use this when comparing tokens whose runtime types may differ (e.g. a + /// [TokenValue] against an enum that `implements Token`). + static bool equals(final Token a, final Token b) => + _ciEquals(a.value, b.value); + + /// Hash code consistent with [equals]: ASCII case-insensitive hash of + /// [t]'s wire value. + static int hashFor(final Token t) => _ciHash(t.value); +} + +/// A free-form [Token] value, validated at construction. +/// +/// Suitable for headers where the spec defines an open set of token values +/// (e.g. `Content-Encoding`, `Connection`). For closed value sets prefer a +/// dedicated `enum implements Token`. +/// +/// [operator ==] and [hashCode] use ASCII case-insensitive value semantics, so +/// `TokenValue('gzip') == TokenValue('GZIP')` is `true`. +@immutable +final class TokenValue implements Token { + @override + final String value; + + /// Creates a [TokenValue], throwing [FormatException] if [value] is not a + /// valid HTTP token per [Token.isValid]. + TokenValue(final String value) : value = Token.validate(value); + + @override + bool operator ==(final Object other) => + identical(this, other) || + (other is TokenValue && _ciEquals(value, other.value)); + + @override + int get hashCode => _ciHash(value); + + @override + String toString() => value; +} + +bool _isTchar(final int c) { + // ALPHA + if (c >= 0x41 && c <= 0x5A) return true; // A-Z + if (c >= 0x61 && c <= 0x7A) return true; // a-z + // DIGIT + if (c >= 0x30 && c <= 0x39) return true; // 0-9 + // tchar specials: ! # $ % & ' * + - . ^ _ ` | ~ + switch (c) { + case 0x21: + case 0x23: + case 0x24: + case 0x25: + case 0x26: + case 0x27: + case 0x2A: + case 0x2B: + case 0x2D: + case 0x2E: + case 0x5E: + case 0x5F: + case 0x60: + case 0x7C: + case 0x7E: + return true; + } + return false; +} + +bool _ciEquals(final String a, final String b) { + if (identical(a, b)) return true; + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (_asciiFold(a.codeUnitAt(i)) != _asciiFold(b.codeUnitAt(i))) { + return false; + } + } + return true; +} + +int _ciHash(final String s) { + // FNV-1a 32-bit over ASCII-folded code units. Adequate for hashing short + // header tokens; not a cryptographic primitive. + var h = 0x811c9dc5; + for (var i = 0; i < s.length; i++) { + h ^= _asciiFold(s.codeUnitAt(i)); + h = (h * 0x01000193) & 0xFFFFFFFF; + } + return h; +} + +int _asciiFold(final int c) { + if (c >= 0x41 && c <= 0x5A) return c + 0x20; // uppercase ASCII -> lowercase + return c; +} diff --git a/packages/relic_core/test/headers/typed/accept_ranges_header_test.dart b/packages/relic_core/test/headers/typed/accept_ranges_header_test.dart new file mode 100644 index 00000000..96153b17 --- /dev/null +++ b/packages/relic_core/test/headers/typed/accept_ranges_header_test.dart @@ -0,0 +1,33 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('AcceptRangesHeader.parse', () { + group('Given a list of range units,', () { + test('when parsed, ' + 'then all units are preserved (lowercased).', () { + final h = AcceptRangesHeader.parse('bytes, Custom-Unit'); + + expect(h.rangeUnits, equals(['bytes', 'custom-unit'])); + expect(h.isBytes, isTrue); + }); + }); + + group('Given the none sentinel alone,', () { + test('when parsed, ' + 'then isNone is true.', () { + expect(AcceptRangesHeader.parse('none').isNone, isTrue); + }); + }); + + group('Given none combined with another unit,', () { + test('when parsed, ' + 'then it throws (none is the exclusive no-support sentinel).', () { + expect( + () => AcceptRangesHeader.parse('bytes, none'), + throwsFormatException, + ); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/access_control_allow_origin_header_test.dart b/packages/relic_core/test/headers/typed/access_control_allow_origin_header_test.dart new file mode 100644 index 00000000..3216619b --- /dev/null +++ b/packages/relic_core/test/headers/typed/access_control_allow_origin_header_test.dart @@ -0,0 +1,47 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('AccessControlAllowOriginHeader equality and hashing', () { + group('Given the wildcard and the opaque-origin (null) values,', () { + test('when their hashCodes are compared, ' + 'then they differ (no bucket-0 collision).', () { + const wildcard = AccessControlAllowOriginHeader.wildcard(); + final opaque = AccessControlAllowOriginHeader.origin( + origin: OpaqueOrigin.instance, + ); + + expect(wildcard.hashCode, isNot(equals(opaque.hashCode))); + expect(wildcard == opaque, isFalse); + }); + }); + + group('Given two wildcard headers,', () { + test('when compared, ' + 'then they are equal and share a hashCode.', () { + const a = AccessControlAllowOriginHeader.wildcard(); + const b = AccessControlAllowOriginHeader.wildcard(); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + expect(a.isWildcard, isTrue); + }); + }); + + group('Given two headers with the same tuple origin,', () { + test('when compared, ' + 'then they are equal and share a hashCode.', () { + final a = AccessControlAllowOriginHeader.origin( + origin: Origin.parse('https://example.com'), + ); + final b = AccessControlAllowOriginHeader.origin( + origin: Origin.parse('https://example.com'), + ); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + expect(a.isWildcard, isFalse); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/authorization_header_test.dart b/packages/relic_core/test/headers/typed/authorization_header_test.dart new file mode 100644 index 00000000..e23cef58 --- /dev/null +++ b/packages/relic_core/test/headers/typed/authorization_header_test.dart @@ -0,0 +1,231 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('DigestAuthorizationHeader encoding', () { + group('Given a Digest header,', () { + test('when encoded, ' + 'then the scheme and first param are separated by a space.', () { + final header = DigestAuthorizationHeader( + username: 'user', + realm: 'realm', + nonce: 'nonce', + uri: '/', + response: 'resp', + ); + + expect(header.headerValue, startsWith('Digest username=')); + expect(header.headerValue, isNot(contains('Digest, '))); + }); + + test('when algorithm/qop/nc are present, ' + 'then they are emitted as bare tokens (not quoted).', () { + final header = DigestAuthorizationHeader( + username: 'user', + realm: 'realm', + nonce: 'nonce', + uri: '/', + response: 'resp', + algorithm: 'MD5', + qop: 'auth', + nc: '00000001', + ); + + expect(header.headerValue, contains('algorithm=MD5')); + expect(header.headerValue, contains('qop=auth')); + expect(header.headerValue, contains('nc=00000001')); + expect(header.headerValue, isNot(contains('algorithm="MD5"'))); + }); + }); + + group('Given a Digest header with a quote in a quoted field,', () { + test('when encoded, ' + 'then the interior quote is escaped as a quoted-pair.', () { + final header = DigestAuthorizationHeader( + username: 'a"b', + realm: 'realm', + nonce: 'nonce', + uri: '/', + response: 'resp', + ); + + expect(header.headerValue, contains(r'username="a\"b"')); + }); + }); + + group( + 'Given a Digest header with a control character in a quoted field,', + () { + test('when encoded, ' + 'then it throws to prevent header injection.', () { + final header = DigestAuthorizationHeader( + username: 'user\r\nInjected: evil', + realm: 'realm', + nonce: 'nonce', + uri: '/', + response: 'resp', + ); + + expect(() => header.headerValue, throwsFormatException); + }); + }, + ); + }); + + group('DigestAuthorizationHeader round-trip', () { + group('Given a header with bare-token algorithm/qop/nc,', () { + test('when encoded and re-parsed, ' + 'then all fields are preserved.', () { + final header = DigestAuthorizationHeader( + username: 'user', + realm: 'realm', + nonce: 'nonce', + uri: '/', + response: 'resp', + algorithm: 'MD5', + qop: 'auth', + nc: '00000001', + cnonce: 'cnonce', + opaque: 'opaque', + ); + + final reparsed = DigestAuthorizationHeader.parse( + header.headerValue.substring('Digest '.length), + ); + + expect(reparsed.username, equals('user')); + expect(reparsed.algorithm, equals('MD5')); + expect(reparsed.qop, equals('auth')); + expect(reparsed.nc, equals('00000001')); + expect(reparsed.cnonce, equals('cnonce')); + expect(reparsed.opaque, equals('opaque')); + }); + }); + + group('Given a username containing an escaped quote,', () { + test('when encoded and re-parsed, ' + 'then the original username is recovered.', () { + final header = DigestAuthorizationHeader( + username: r'a"b', + realm: 'realm', + nonce: 'nonce', + uri: '/', + response: 'resp', + ); + + final reparsed = DigestAuthorizationHeader.parse( + header.headerValue.substring('Digest '.length), + ); + + expect(reparsed.username, equals(r'a"b')); + }); + }); + }); + + group('DigestAuthorizationHeader.parse with bare tokens', () { + group('Given a wire value with unquoted algorithm/qop/nc,', () { + test('when parsed, ' + 'then the token-form parameters are captured.', () { + final header = DigestAuthorizationHeader.parse( + 'username="user", realm="realm", nonce="n", uri="/", ' + 'response="r", algorithm=MD5, qop=auth, nc=00000001', + ); + + expect(header.algorithm, equals('MD5')); + expect(header.qop, equals('auth')); + expect(header.nc, equals('00000001')); + }); + }); + + group('Given a bare auth-param value that is not a token,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect( + () => DigestAuthorizationHeader.parse( + 'username="user", realm="r", nonce="n", uri="/", ' + 'response="r", algorithm=MD5;evil', + ), + throwsFormatException, + ); + }); + }); + }); + + group('DigestAuthorizationHeader construction', () { + group('Given a non-token algorithm/qop,', () { + test('when constructed, ' + 'then it throws (these are emitted unquoted).', () { + expect( + () => DigestAuthorizationHeader( + username: 'u', + realm: 'r', + nonce: 'n', + uri: '/', + response: 'r', + algorithm: 'MD5\r\nInjected: 1', + ), + throwsFormatException, + ); + }); + }); + + group('Given an nc that is not exactly 8 hex digits,', () { + test('when constructed, ' + 'then it throws (RFC 7616 nc-value = 8LHEX).', () { + for (final bad in ['123', '0000000g', '000000010']) { + expect( + () => DigestAuthorizationHeader( + username: 'u', + realm: 'r', + nonce: 'n', + uri: '/', + response: 'r', + nc: bad, + ), + throwsFormatException, + reason: 'nc="$bad" should be rejected', + ); + } + }); + }); + }); + + group('AuthorizationHeader scheme dispatch', () { + group('Given a scheme in lowercase,', () { + test('when parsed, ' + 'then it is matched case-insensitively.', () { + expect( + AuthorizationHeader.parse('bearer abc123'), + isA(), + ); + expect( + AuthorizationHeader.parse( + 'DIGEST username="u", realm="r", ' + 'nonce="n", uri="/", response="r"', + ), + isA(), + ); + }); + }); + }); + + group('BasicAuthorizationHeader empty password', () { + group('Given an empty password (apikey pattern),', () { + test('when constructed and round-tripped, ' + 'then the empty password is preserved.', () { + final header = BasicAuthorizationHeader( + username: 'apikey', + password: '', + ); + + expect(header.password, isEmpty); + + final reparsed = + AuthorizationHeader.parse(header.headerValue) + as BasicAuthorizationHeader; + expect(reparsed.username, equals('apikey')); + expect(reparsed.password, isEmpty); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/connection_header_test.dart b/packages/relic_core/test/headers/typed/connection_header_test.dart new file mode 100644 index 00000000..66b10171 --- /dev/null +++ b/packages/relic_core/test/headers/typed/connection_header_test.dart @@ -0,0 +1,58 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('ConnectionHeader.parse', () { + group('Given an empty value,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => ConnectionHeader.parse(['']), throwsFormatException); + }); + }); + + group('Given a non-token connection-option,', () { + test('when parsed, ' + 'then it throws (RFC 9110 7.6.1 connection-option is a token).', () { + expect( + () => ConnectionHeader.parse(['bad directive']), + throwsFormatException, + ); + }); + }); + + group('Given a valid multi-directive value,', () { + test('when parsed, ' + 'then the directives are preserved and lowercased.', () { + final header = ConnectionHeader.parse(['keep-alive, Upgrade']); + + expect( + header.directives.map((final d) => d.value), + equals(['keep-alive', 'upgrade']), + ); + }); + }); + + group('Given an unknown but valid connection-option,', () { + test('when parsed, ' + 'then it is accepted (open token set).', () { + final header = ConnectionHeader.parse(['TE']); + + expect(header.directives.single.value, equals('te')); + }); + }); + + group('Given duplicate directives,', () { + test('when parsed, ' + 'then exact duplicates are removed.', () { + final header = ConnectionHeader.parse([ + 'keep-alive, upgrade, keep-alive', + ]); + + expect( + header.directives.map((final d) => d.value), + equals(['keep-alive', 'upgrade']), + ); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/content_language_header_test.dart b/packages/relic_core/test/headers/typed/content_language_header_test.dart new file mode 100644 index 00000000..8ebf0ea8 --- /dev/null +++ b/packages/relic_core/test/headers/typed/content_language_header_test.dart @@ -0,0 +1,25 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('ContentLanguageHeader.parse', () { + group('Given mixed-case BCP 47 tags,', () { + test('when parsed, ' + 'then they are stored in canonical case.', () { + final h = ContentLanguageHeader.parse(['en-us, ZH-hant-tw']); + + expect(h.languages, equals(['en-US', 'zh-Hant-TW'])); + }); + }); + + group('Given two tags that differ only in case,', () { + test('when parsed, ' + 'then they canonicalize to equal headers.', () { + expect( + ContentLanguageHeader.parse(['en-US']), + equals(ContentLanguageHeader.parse(['EN-us'])), + ); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/content_range_header_test.dart b/packages/relic_core/test/headers/typed/content_range_header_test.dart new file mode 100644 index 00000000..4a50248a --- /dev/null +++ b/packages/relic_core/test/headers/typed/content_range_header_test.dart @@ -0,0 +1,160 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('ContentRangeHeader construction invariants', () { + group('Given start set but end null,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect( + () => ContentRangeHeader(start: 0, end: null, size: 100), + throwsFormatException, + ); + }); + }); + + group('Given end set but start null,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect( + () => ContentRangeHeader(start: null, end: 99, size: 100), + throwsFormatException, + ); + }); + }); + + group('Given start, end, and size all null,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect( + () => ContentRangeHeader(start: null, end: null, size: null), + throwsFormatException, + ); + }); + }); + + group('Given start > end,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect( + () => ContentRangeHeader(start: 100, end: 50, size: 1000), + throwsFormatException, + ); + }); + }); + + group('Given a negative member,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect( + () => ContentRangeHeader(start: -1, end: 5, size: 100), + throwsFormatException, + ); + expect( + () => ContentRangeHeader(start: 0, end: 5, size: -1), + throwsFormatException, + ); + }); + }); + + group('Given the unsatisfied-range form (no range, size set),', () { + test('when constructed and encoded, ' + 'then the wire form is "unit */size".', () { + final h = ContentRangeHeader(size: 1234); + + expect(h.toString(), contains('start: null')); + expect(ContentRangeHeader.codec.encode(h), equals(['bytes */1234'])); + }); + }); + + group('Given a satisfied range,', () { + test('when encoded, ' + 'then the wire form is "unit start-end/size".', () { + final h = ContentRangeHeader(start: 0, end: 499, size: 1234); + + expect( + ContentRangeHeader.codec.encode(h), + equals(['bytes 0-499/1234']), + ); + }); + + test('when encoded with unknown total size, ' + 'then size renders as "*".', () { + final h = ContentRangeHeader(start: 0, end: 499); + + expect(ContentRangeHeader.codec.encode(h), equals(['bytes 0-499/*'])); + }); + }); + }); + + group('ContentRangeHeader.parse', () { + group('Given a satisfied byte range with known size,', () { + test('when parsed, ' + 'then start, end, and size are populated.', () { + final h = ContentRangeHeader.parse('bytes 0-499/1234'); + + expect(h.unit, equals('bytes')); + expect(h.start, equals(0)); + expect(h.end, equals(499)); + expect(h.size, equals(1234)); + }); + }); + + group('Given an unsatisfied range with known size,', () { + test('when parsed, ' + 'then start and end are null and size is set.', () { + final h = ContentRangeHeader.parse('bytes */1234'); + + expect(h.start, isNull); + expect(h.end, isNull); + expect(h.size, equals(1234)); + }); + }); + + group('Given a "bytes */*" header,', () { + test('when parsed, ' + 'then it throws because the unsatisfied form requires a length.', () { + expect( + () => ContentRangeHeader.parse('bytes */*'), + throwsFormatException, + ); + }); + }); + + group('Given a valid range followed by trailing garbage,', () { + test('when parsed, ' + 'then it throws instead of silently dropping the tail.', () { + expect( + () => ContentRangeHeader.parse('bytes 0-499/1234 evil'), + throwsFormatException, + ); + }); + }); + + group('Given leading garbage before a valid range,', () { + test('when parsed, ' + 'then it throws.', () { + expect( + () => ContentRangeHeader.parse('x bytes 0-499/1234'), + throwsFormatException, + ); + }); + }); + + group('Given a numeric field too large to represent,', () { + test('when parsed, ' + 'then it throws instead of silently becoming unsatisfied.', () { + expect( + () => ContentRangeHeader.parse( + 'bytes 99999999999999999999-99999999999999999999/100', + ), + throwsFormatException, + ); + expect( + () => ContentRangeHeader.parse('bytes 0-10/99999999999999999999'), + throwsFormatException, + ); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/cross_origin_policy_header_test.dart b/packages/relic_core/test/headers/typed/cross_origin_policy_header_test.dart new file mode 100644 index 00000000..391499bc --- /dev/null +++ b/packages/relic_core/test/headers/typed/cross_origin_policy_header_test.dart @@ -0,0 +1,105 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('CrossOriginEmbedderPolicyHeader.parse', () { + group('Given a policy with a report-to parameter,', () { + test('when parsed, ' + 'then the policy and report-to are captured and round-trip.', () { + final h = CrossOriginEmbedderPolicyHeader.parse( + 'require-corp; report-to="coep-endpoint"', + ); + + expect(h.policy, equals('require-corp')); + expect(h.reportTo, equals('coep-endpoint')); + expect( + CrossOriginEmbedderPolicyHeader.codec.encode(h).single, + equals('require-corp; report-to="coep-endpoint"'), + ); + }); + }); + + group('Given a report-to value containing a semicolon (quoted),', () { + test('when parsed, ' + 'then the semicolon does not split the value.', () { + final h = CrossOriginEmbedderPolicyHeader.parse( + 'require-corp; report-to="a;b"', + ); + + expect(h.reportTo, equals('a;b')); + }); + }); + + group('Given a report-to value with an escaped quote on the wire,', () { + test('when parsed, encoded, and re-parsed, ' + 'then the quote round-trips.', () { + final parsed = CrossOriginEmbedderPolicyHeader.parse( + r'require-corp; report-to="a\"b"', + ); + expect(parsed.reportTo, equals(r'a"b')); + + final wire = CrossOriginEmbedderPolicyHeader.codec + .encode(parsed) + .single; + final reparsed = CrossOriginEmbedderPolicyHeader.parse(wire); + expect(reparsed.reportTo, equals(r'a"b')); + }); + }); + + group('Given a report-to value with a quoted-pair over a plain char,', () { + test('when parsed, ' + 'then the backslash escape is removed (RFC 9110 quoted-pair).', () { + final h = CrossOriginEmbedderPolicyHeader.parse( + r'require-corp; report-to="a\b"', + ); + + expect(h.reportTo, equals('ab')); + }); + }); + + group('Given trailing characters after a quoted report-to value,', () { + test('when parsed, ' + 'then it throws.', () { + expect( + () => CrossOriginEmbedderPolicyHeader.parse( + 'require-corp; report-to="a"x', + ), + throwsFormatException, + ); + }); + }); + + group('Given an unknown policy token,', () { + test('when parsed, ' + 'then it throws.', () { + expect( + () => CrossOriginEmbedderPolicyHeader.parse('made-up'), + throwsFormatException, + ); + }); + }); + }); + + group('CrossOriginOpenerPolicyHeader.parse', () { + group('Given the noopener-allow-popups value,', () { + test('when parsed, ' + 'then it is accepted.', () { + final h = CrossOriginOpenerPolicyHeader.parse('noopener-allow-popups'); + + expect(h.policy, equals('noopener-allow-popups')); + }); + }); + + group('Given a policy with a report-to parameter,', () { + test('when parsed, ' + 'then the report-to is captured.', () { + final h = CrossOriginOpenerPolicyHeader.parse( + 'same-origin; report-to="coop-endpoint"', + ); + + expect(h.policy, equals('same-origin')); + expect(h.reportTo, equals('coop-endpoint')); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/etag_header_test.dart b/packages/relic_core/test/headers/typed/etag_header_test.dart new file mode 100644 index 00000000..933c320c --- /dev/null +++ b/packages/relic_core/test/headers/typed/etag_header_test.dart @@ -0,0 +1,37 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('ETagHeader.parse', () { + group('Given a strong etag,', () { + test('when parsed, ' + 'then the opaque-tag and weak flag are recovered.', () { + final etag = ETagHeader.parse('"abc"'); + + expect(etag.value, equals('abc')); + expect(etag.isWeak, isFalse); + }); + }); + + group('Given a weak etag,', () { + test('when parsed, ' + 'then isWeak is true.', () { + expect(ETagHeader.parse('W/"abc"').isWeak, isTrue); + }); + }); + + group('Given an interior double-quote in the opaque-tag,', () { + test('when parsed, ' + 'then it is rejected (etagc-validated, not silently stripped).', () { + expect(() => ETagHeader.parse('"a"b"'), throwsFormatException); + }); + }); + + group('Given whitespace between W/ and the opening quote,', () { + test('when parsed, ' + 'then it is rejected.', () { + expect(() => ETagHeader.parse('W/ "abc"'), throwsFormatException); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/expect_header_test.dart b/packages/relic_core/test/headers/typed/expect_header_test.dart new file mode 100644 index 00000000..4428b49f --- /dev/null +++ b/packages/relic_core/test/headers/typed/expect_header_test.dart @@ -0,0 +1,47 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('ExpectHeader.parse', () { + group('Given the standard 100-continue value,', () { + test('when parsed (any case), ' + 'then it returns the continue100 constant.', () { + expect( + ExpectHeader.parse('100-continue'), + same(ExpectHeader.continue100), + ); + expect( + ExpectHeader.parse('100-Continue'), + same(ExpectHeader.continue100), + ); + }); + }); + + group('Given an unknown expectation,', () { + test('when parsed, ' + 'then the value is preserved so a server can answer 417.', () { + expect( + ExpectHeader.parse('custom-directive').value, + equals('custom-directive'), + ); + }); + }); + + group('Given a value containing a control character,', () { + test('when parsed, ' + 'then it throws to prevent header injection.', () { + expect( + () => ExpectHeader.parse('100-continue\r\nX-Injected: yes'), + throwsFormatException, + ); + }); + }); + + group('Given an unknown expectation containing a HTAB,', () { + test('when parsed, ' + 'then it is accepted (HTAB is legal OWS).', () { + expect(ExpectHeader.parse('foo\tbar').value, equals('foo\tbar')); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/from_header_test.dart b/packages/relic_core/test/headers/typed/from_header_test.dart new file mode 100644 index 00000000..41ee65e2 --- /dev/null +++ b/packages/relic_core/test/headers/typed/from_header_test.dart @@ -0,0 +1,42 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('FromHeader.parse', () { + group('Given a plain addr-spec,', () { + test('when parsed, ' + 'then it is the mailbox.', () { + expect( + FromHeader.parse('user@example.com').mailbox, + equals('user@example.com'), + ); + }); + }); + + group('Given a name-addr with a comma in the display-name,', () { + test( + 'when parsed, ' + 'then it is kept intact (a single mailbox, not split on the comma).', + () { + final from = FromHeader.parse('"Doe, John" '); + + expect(from.mailbox, equals('"Doe, John" ')); + }, + ); + }); + + group('Given surrounding whitespace,', () { + test('when parsed, ' + 'then the mailbox is trimmed.', () { + expect(FromHeader.parse(' a@b ').mailbox, equals('a@b')); + }); + }); + + group('Given an empty value,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => FromHeader.parse(''), throwsFormatException); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/host_header_test.dart b/packages/relic_core/test/headers/typed/host_header_test.dart new file mode 100644 index 00000000..7a3f8ce7 --- /dev/null +++ b/packages/relic_core/test/headers/typed/host_header_test.dart @@ -0,0 +1,109 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('HostHeader.fromUri default-port handling', () { + group('Given a URI with an explicit port,', () { + test('when HostHeader.fromUri is called, ' + 'then the port is preserved.', () { + final h = HostHeader.fromUri(Uri.parse('http://example.com:8080/')); + + expect(h.host, equals('example.com')); + expect(h.port, equals(8080)); + }); + }); + + group('Given an http URI with the default (implicit) port,', () { + test('when HostHeader.fromUri is called, ' + 'then port is null (no coercion to 80).', () { + final h = HostHeader.fromUri(Uri.parse('http://example.com/')); + + expect(h.port, isNull); + }); + }); + + group('Given an https URI with the default (implicit) port,', () { + test('when HostHeader.fromUri is called, ' + 'then port is null (no coercion to 443).', () { + final h = HostHeader.fromUri(Uri.parse('https://example.com/')); + + expect(h.port, isNull); + }); + }); + + group('Given an HTTP URI with no port and no path,', () { + test('when HostHeader.fromUri is called, ' + 'then port is null.', () { + final h = HostHeader.fromUri(Uri.parse('http://example.com')); + + expect(h.port, isNull); + }); + }); + + group('Given an IPv6 URI,', () { + test('when HostHeader.fromUri is called, ' + 'then the host keeps its brackets and encodes unambiguously.', () { + final h = HostHeader.fromUri(Uri.parse('http://[::1]:8080/')); + + expect(h.host, equals('[::1]')); + expect(h.port, equals(8080)); + }); + + test('when compared with the equivalent parse(), ' + 'then the two are equal.', () { + final fromUri = HostHeader.fromUri(Uri.parse('http://[::1]:8080/')); + final parsed = HostHeader.parse('[::1]:8080'); + + expect(fromUri, equals(parsed)); + }); + }); + }); + + group('HostHeader factory validation', () { + group('Given a URI with no host,', () { + test('when HostHeader.fromUri is called, ' + 'then it throws a FormatException (matching parse).', () { + expect( + () => HostHeader.fromUri(Uri.parse('http:///path')), + throwsFormatException, + ); + }); + }); + + group('Given an empty host,', () { + test('when HostHeader is constructed, ' + 'then it throws a FormatException.', () { + expect(() => HostHeader(''), throwsFormatException); + }); + }); + + group('Given an out-of-range port,', () { + test('when HostHeader is constructed, ' + 'then it throws a FormatException (not a RangeError).', () { + expect(() => HostHeader('h', 70000), throwsFormatException); + }); + }); + + group('Given a non-digit port in parse,', () { + test('when HostHeader.parse is called, ' + 'then hex, signs, and whitespace are rejected.', () { + expect( + () => HostHeader.parse('example.com:0x10'), + throwsFormatException, + ); + expect( + () => HostHeader.parse('example.com:+80'), + throwsFormatException, + ); + expect( + () => HostHeader.parse('example.com: 80'), + throwsFormatException, + ); + expect( + () => HostHeader.parse('example.com:99999999999'), + throwsFormatException, + ); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/permission_policy_header_test.dart b/packages/relic_core/test/headers/typed/permission_policy_header_test.dart new file mode 100644 index 00000000..6e31781c --- /dev/null +++ b/packages/relic_core/test/headers/typed/permission_policy_header_test.dart @@ -0,0 +1,167 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('PermissionsPolicyHeader.parse', () { + group('Given an inner-list with an sf-string containing a space,', () { + test('when parsed, ' + 'then the sf-string is kept as a single value.', () { + final header = PermissionsPolicyHeader.parse('camera=("hello world")'); + + final values = header.directives.single.values.toList(); + expect(values, equals(['hello world'])); + }); + }); + + group('Given a directive value containing an "=" (URL query),', () { + test('when parsed, ' + 'then the full value after the first "=" is preserved.', () { + final header = PermissionsPolicyHeader.parse( + 'geolocation=("https://x.com?a=1")', + ); + + final values = header.directives.single.values.toList(); + expect(values, equals(['https://x.com?a=1'])); + }); + }); + + group('Given a value with a comma inside an sf-string,', () { + test('when parsed, ' + 'then the comma does not split the directive.', () { + final header = PermissionsPolicyHeader.parse('camera=("a,b")'); + + expect(header.directives, hasLength(1)); + expect(header.directives.single.values.toList(), equals(['a,b'])); + }); + }); + + group('Given mixed token and sf-string items,', () { + test('when parsed, ' + 'then both are recovered.', () { + final header = PermissionsPolicyHeader.parse( + 'geolocation=(self "https://example.com")', + ); + + expect( + header.directives.single.values.toList(), + equals(['self', 'https://example.com']), + ); + }); + }); + + group('Given an unbalanced inner-list,', () { + test('when parsed, ' + 'then it throws.', () { + expect( + () => PermissionsPolicyHeader.parse('geolocation=(self'), + throwsFormatException, + ); + expect( + () => PermissionsPolicyHeader.parse('geolocation=self)'), + throwsFormatException, + ); + }); + }); + + group('Given an empty feature name,', () { + test('when parsed, ' + 'then it throws.', () { + expect( + () => PermissionsPolicyHeader.parse('=()'), + throwsFormatException, + ); + }); + + test('when a header is built from a directive with an empty name, ' + 'then it throws (matching the parser).', () { + expect( + () => PermissionsPolicyHeader.directives([ + PermissionsPolicyDirective(name: '', values: const []), + ]), + throwsFormatException, + ); + }); + }); + + group('Given no directives,', () { + test('when a header is built from an empty list, ' + 'then it throws (the invariant holds in release too).', () { + expect( + () => PermissionsPolicyHeader.directives(const []), + throwsFormatException, + ); + }); + }); + + group('Given a feature name that is not a token,', () { + test( + 'when a header is built from it, ' + 'then it throws (a space would serialize a malformed directive).', + () { + expect( + () => PermissionsPolicyHeader.directives([ + PermissionsPolicyDirective( + name: 'geolocation allow', + values: const [], + ), + ]), + throwsFormatException, + ); + }, + ); + }); + }); + + group('PermissionsPolicyHeader encoding', () { + group('Given an origin value,', () { + test('when encoded, ' + 'then it is rendered as a quoted sf-string.', () { + final header = PermissionsPolicyHeader.directives([ + PermissionsPolicyDirective( + name: 'geolocation', + values: ['self', 'https://example.com'], + ), + ]); + + expect( + PermissionsPolicyHeader.codec.encode(header).single, + equals('geolocation=(self "https://example.com")'), + ); + }); + }); + + group('Given an sf-string value round-tripped through the codec,', () { + test('when re-parsed, ' + 'then the original values are recovered.', () { + final header = PermissionsPolicyHeader.directives([ + PermissionsPolicyDirective(name: 'camera', values: ['hello world']), + ]); + + final wire = PermissionsPolicyHeader.codec.encode(header).single; + final reparsed = PermissionsPolicyHeader.parse(wire); + + expect( + reparsed.directives.single.values.toList(), + equals(['hello world']), + ); + }); + }); + + group('Given a value containing a control character,', () { + test('when encoded, ' + 'then it throws to prevent header injection.', () { + final header = PermissionsPolicyHeader.directives([ + PermissionsPolicyDirective( + name: 'geolocation', + values: ['https://x.com\r\nSet-Cookie: evil=1'], + ), + ]); + + expect( + () => PermissionsPolicyHeader.codec.encode(header), + throwsFormatException, + ); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/primitives/delta_seconds_test.dart b/packages/relic_core/test/headers/typed/primitives/delta_seconds_test.dart new file mode 100644 index 00000000..3cef6beb --- /dev/null +++ b/packages/relic_core/test/headers/typed/primitives/delta_seconds_test.dart @@ -0,0 +1,106 @@ +import 'package:relic_core/src/headers/typed/primitives/delta_seconds.dart'; +import 'package:test/test.dart'; + +void main() { + group('DeltaSeconds construction', () { + group('Given a non-negative int,', () { + test('when DeltaSeconds is constructed, ' + 'then the seconds value is preserved.', () { + expect(DeltaSeconds(0).seconds, equals(0)); + expect(DeltaSeconds(31536000).seconds, equals(31536000)); + }); + }); + + group('Given a negative int,', () { + test('when DeltaSeconds is constructed, ' + 'then it throws a FormatException.', () { + expect(() => DeltaSeconds(-1), throwsFormatException); + expect(() => DeltaSeconds(-31536000), throwsFormatException); + }); + }); + }); + + group('DeltaSeconds.parse', () { + group('Given a digits-only string,', () { + test('when parsed, ' + 'then the resulting seconds match int.parse.', () { + expect(DeltaSeconds.parse('0').seconds, equals(0)); + expect(DeltaSeconds.parse('31536000').seconds, equals(31536000)); + }); + }); + + group('Given an empty string,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => DeltaSeconds.parse(''), throwsFormatException); + }); + }); + + group('Given a string with a leading sign,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => DeltaSeconds.parse('+5'), throwsFormatException); + expect(() => DeltaSeconds.parse('-1'), throwsFormatException); + }); + }); + + group('Given a string with surrounding whitespace,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => DeltaSeconds.parse(' 5'), throwsFormatException); + expect(() => DeltaSeconds.parse('5 '), throwsFormatException); + }); + }); + + group('Given a string with non-digit characters,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => DeltaSeconds.parse('5a'), throwsFormatException); + expect(() => DeltaSeconds.parse('1.5'), throwsFormatException); + expect(() => DeltaSeconds.parse('0x10'), throwsFormatException); + }); + }); + + group('Given an all-digit value too large to represent,', () { + test('when parsed, ' + 'then it is clamped to maxValue instead of overflowing.', () { + expect( + DeltaSeconds.parse('99999999999999999999').seconds, + equals(DeltaSeconds.maxValue), + ); + expect( + DeltaSeconds.parse('9007199254740993').seconds, + equals(DeltaSeconds.maxValue), + ); + }); + }); + + group('Given a value just under the clamp ceiling,', () { + test('when parsed, ' + 'then it is preserved exactly.', () { + expect(DeltaSeconds.parse('2147483647').seconds, equals(2147483647)); + }); + }); + }); + + group('DeltaSeconds.encode', () { + group('Given a DeltaSeconds value,', () { + test('when encoded, ' + 'then the result is the decimal digits of seconds.', () { + expect(DeltaSeconds(0).encode(), equals('0')); + expect(DeltaSeconds(60).encode(), equals('60')); + expect(DeltaSeconds(31536000).encode(), equals('31536000')); + }); + + test('when round-tripped through parse, ' + 'then the seconds value is preserved.', () { + for (final s in const [0, 1, 30, 3600, 86400, 31536000]) { + expect( + DeltaSeconds.parse(DeltaSeconds(s).encode()).seconds, + equals(s), + ); + } + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/primitives/etag_value_test.dart b/packages/relic_core/test/headers/typed/primitives/etag_value_test.dart new file mode 100644 index 00000000..eeebef4c --- /dev/null +++ b/packages/relic_core/test/headers/typed/primitives/etag_value_test.dart @@ -0,0 +1,201 @@ +import 'package:relic_core/src/headers/typed/primitives/etag_value.dart'; +import 'package:test/test.dart'; + +void main() { + group('ETagValue construction', () { + group('Given a strong tag with valid etagc content,', () { + test('when constructed, ' + 'then value and isWeak are preserved.', () { + final t = ETagValue(value: 'abc123'); + + expect(t.value, equals('abc123')); + expect(t.isWeak, isFalse); + }); + }); + + group('Given a weak tag,', () { + test('when constructed with isWeak: true, ' + 'then the weak flag is set.', () { + final t = ETagValue(value: 'abc', isWeak: true); + + expect(t.isWeak, isTrue); + }); + }); + + group('Given an opaque-tag value containing a double quote,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect(() => ETagValue(value: 'abc"def'), throwsFormatException); + }); + }); + + group('Given an opaque-tag value containing a control character,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect(() => ETagValue(value: 'a\tb'), throwsFormatException); + expect(() => ETagValue(value: 'a\nb'), throwsFormatException); + expect(() => ETagValue(value: 'a\x01b'), throwsFormatException); + }); + }); + + group('Given an empty opaque-tag value,', () { + test('when constructed, ' + 'then it is accepted.', () { + expect(ETagValue(value: '').value, equals('')); + }); + }); + }); + + group('ETagValue.parse', () { + group('Given a quoted strong tag,', () { + test('when parsed, ' + 'then value is the opaque-tag and isWeak is false.', () { + final t = ETagValue.parse('"abc123"'); + + expect(t.value, equals('abc123')); + expect(t.isWeak, isFalse); + }); + }); + + group('Given a quoted weak tag,', () { + test('when parsed, ' + 'then isWeak is true and value is the opaque-tag.', () { + final t = ETagValue.parse('W/"abc"'); + + expect(t.value, equals('abc')); + expect(t.isWeak, isTrue); + }); + }); + + group('Given lowercase "w/" prefix,', () { + test( + 'when parsed, ' + 'then it throws a FormatException (weak marker is case-sensitive).', + () { + expect(() => ETagValue.parse('w/"abc"'), throwsFormatException); + }, + ); + }); + + group('Given a value with no surrounding quotes,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => ETagValue.parse('abc'), throwsFormatException); + }); + }); + + group('Given a missing closing quote,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => ETagValue.parse('"abc'), throwsFormatException); + }); + }); + + group('Given an empty quoted tag,', () { + test('when parsed, ' + 'then it is accepted with an empty value.', () { + final t = ETagValue.parse('""'); + + expect(t.value, equals('')); + expect(t.isWeak, isFalse); + }); + }); + }); + + group('ETagValue.encode', () { + group('Given a strong tag,', () { + test('when encoded, ' + 'then the wire form has no W/ prefix.', () { + expect(ETagValue(value: 'abc').encode(), equals('"abc"')); + }); + }); + + group('Given a weak tag,', () { + test('when encoded, ' + 'then the wire form has the W/ prefix.', () { + expect( + ETagValue(value: 'abc', isWeak: true).encode(), + equals('W/"abc"'), + ); + }); + }); + + group('Given a tag round-tripped through parse,', () { + test('when re-encoded, ' + 'then the wire form matches the input.', () { + for (final input in const ['""', '"abc"', '"a/b-c.d"', 'W/"abc"']) { + expect(ETagValue.parse(input).encode(), equals(input)); + } + }); + }); + }); + + group('ETagValue match helpers', () { + group('Given two strong tags with identical opaque-tags,', () { + test('when compared with strongMatches, ' + 'then they match.', () { + final a = ETagValue(value: 'abc'); + final b = ETagValue(value: 'abc'); + + expect(a.strongMatches(b), isTrue); + expect(a.weakMatches(b), isTrue); + }); + }); + + group('Given a strong and a weak tag with the same opaque-tag,', () { + test('when compared, ' + 'then they fail strong match but pass weak match.', () { + final strong = ETagValue(value: 'abc'); + final weak = ETagValue(value: 'abc', isWeak: true); + + expect(strong.strongMatches(weak), isFalse); + expect(strong.weakMatches(weak), isTrue); + }); + }); + + group('Given two weak tags with the same opaque-tag,', () { + test('when compared, ' + 'then they fail strong match but pass weak match.', () { + final a = ETagValue(value: 'abc', isWeak: true); + final b = ETagValue(value: 'abc', isWeak: true); + + expect(a.strongMatches(b), isFalse); + expect(a.weakMatches(b), isTrue); + }); + }); + + group('Given two tags with different opaque-tags,', () { + test('when compared, ' + 'then they fail both strong and weak match.', () { + final a = ETagValue(value: 'abc'); + final b = ETagValue(value: 'def'); + + expect(a.strongMatches(b), isFalse); + expect(a.weakMatches(b), isFalse); + }); + }); + }); + + group('ETagValue equality', () { + group('Given two tags with same parts,', () { + test('when compared with ==, ' + 'then they are equal and share hashCode.', () { + final a = ETagValue(value: 'abc', isWeak: true); + final b = ETagValue(value: 'abc', isWeak: true); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('Given two tags that differ only in weak flag,', () { + test('when compared with ==, ' + 'then they are not equal.', () { + final a = ETagValue(value: 'abc'); + final b = ETagValue(value: 'abc', isWeak: true); + + expect(a, isNot(equals(b))); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/primitives/header_scanner_test.dart b/packages/relic_core/test/headers/typed/primitives/header_scanner_test.dart new file mode 100644 index 00000000..d746445c --- /dev/null +++ b/packages/relic_core/test/headers/typed/primitives/header_scanner_test.dart @@ -0,0 +1,402 @@ +import 'package:relic_core/src/headers/typed/primitives/header_scanner.dart'; +import 'package:test/test.dart'; + +const int $comma = 0x2C; +const int $semicolon = 0x3B; +const int $equals = 0x3D; +const int $dquote = 0x22; + +void main() { + group('HeaderScanner basics', () { + group('Given an empty source string,', () { + test('when constructed, ' + 'then position is 0 and atEnd is true.', () { + final s = HeaderScanner(''); + + expect(s.position, equals(0)); + expect(s.atEnd, isTrue); + expect(s.remaining, equals(0)); + expect(s.peek(), equals(-1)); + }); + }); + + group('Given a non-empty source,', () { + test('when peek is called, ' + 'then it returns the code unit at the cursor without advancing.', () { + final s = HeaderScanner('ab'); + + expect(s.peek(), equals(0x61)); + expect(s.position, equals(0)); + }); + + test('when position is set within bounds, ' + 'then the cursor moves.', () { + final s = HeaderScanner('abc'); + + s.position = 2; + expect(s.peek(), equals(0x63)); + expect(s.remaining, equals(1)); + }); + + test('when position is set out of bounds, ' + 'then it throws a RangeError.', () { + final s = HeaderScanner('abc'); + + expect(() => s.position = -1, throwsRangeError); + expect(() => s.position = 4, throwsRangeError); + }); + }); + }); + + group('HeaderScanner.skipOws', () { + group('Given a string with leading SP and HTAB,', () { + test('when skipOws is called, ' + 'then all SP and HTAB are consumed.', () { + final s = HeaderScanner(' \t \tabc'); + + s.skipOws(); + + expect(s.position, equals(5)); + expect(s.peek(), equals(0x61)); + }); + }); + + group('Given a string with no leading OWS,', () { + test('when skipOws is called, ' + 'then the cursor does not move.', () { + final s = HeaderScanner('abc'); + + s.skipOws(); + + expect(s.position, equals(0)); + }); + }); + + group('Given a position at end of source,', () { + test('when skipOws is called, ' + 'then it is a no-op.', () { + final s = HeaderScanner('abc')..position = 3; + + s.skipOws(); + + expect(s.atEnd, isTrue); + }); + }); + }); + + group('HeaderScanner.tryConsume and expect', () { + group('Given a cursor pointing at a specific character,', () { + test('when tryConsume is called with that char, ' + 'then it returns true and advances.', () { + final s = HeaderScanner(',a'); + + expect(s.tryConsume($comma), isTrue); + expect(s.position, equals(1)); + }); + + test('when tryConsume is called with a different char, ' + 'then it returns false and does not advance.', () { + final s = HeaderScanner(',a'); + + expect(s.tryConsume($semicolon), isFalse); + expect(s.position, equals(0)); + }); + + test('when expect is called with that char, ' + 'then it advances without error.', () { + final s = HeaderScanner(',a'); + + s.expect($comma); + + expect(s.position, equals(1)); + }); + + test('when expect is called with a different char, ' + 'then it throws a FormatException.', () { + final s = HeaderScanner(',a'); + + expect(() => s.expect($semicolon), throwsFormatException); + }); + }); + }); + + group('HeaderScanner.tryReadToken', () { + group('Given a token at the cursor,', () { + test('when tryReadToken is called, ' + 'then it returns the token and advances past it.', () { + final s = HeaderScanner('gzip;q=0.5'); + + final token = s.tryReadToken(); + + expect(token, equals('gzip')); + expect(s.position, equals(4)); + }); + }); + + group('Given non-tchar at the cursor,', () { + test('when tryReadToken is called, ' + 'then it returns null and does not advance.', () { + final s = HeaderScanner(' gzip'); + + final token = s.tryReadToken(); + + expect(token, isNull); + expect(s.position, equals(0)); + }); + }); + + group('Given an empty source,', () { + test('when readToken is called, ' + 'then it throws a FormatException.', () { + final s = HeaderScanner(''); + + expect(s.readToken, throwsFormatException); + }); + }); + }); + + group('HeaderScanner.tryReadQuotedString', () { + group('Given a simple quoted-string,', () { + test( + 'when tryReadQuotedString is called, ' + 'then it returns the inner text and advances past the closing quote.', + () { + final s = HeaderScanner('"hello"world'); + + final value = s.tryReadQuotedString(); + + expect(value, equals('hello')); + expect(s.position, equals(7)); + }, + ); + }); + + group('Given a quoted-string containing quoted-pair escapes,', () { + test('when tryReadQuotedString is called, ' + 'then the escapes are decoded.', () { + final s = HeaderScanner(r'"a\"b\\c"'); + + final value = s.tryReadQuotedString(); + + expect(value, equals(r'a"b\c')); + expect(s.atEnd, isTrue); + }); + }); + + group('Given an empty quoted-string,', () { + test('when tryReadQuotedString is called, ' + 'then it returns the empty string.', () { + final s = HeaderScanner('""'); + + final value = s.tryReadQuotedString(); + + expect(value, equals('')); + expect(s.atEnd, isTrue); + }); + }); + + group('Given a cursor not pointing at a quote,', () { + test('when tryReadQuotedString is called, ' + 'then it returns null without advancing.', () { + final s = HeaderScanner('hello'); + + final value = s.tryReadQuotedString(); + + expect(value, isNull); + expect(s.position, equals(0)); + }); + }); + + group('Given an unterminated quoted-string,', () { + test('when tryReadQuotedString is called, ' + 'then it throws and rewinds the cursor.', () { + final s = HeaderScanner('"missing-close'); + + expect(s.tryReadQuotedString, throwsFormatException); + expect(s.position, equals(0)); + }); + }); + + group('Given a dangling backslash before EOF,', () { + test('when tryReadQuotedString is called, ' + 'then it throws and rewinds the cursor.', () { + final s = HeaderScanner(r'"abc\'); + + expect(s.tryReadQuotedString, throwsFormatException); + expect(s.position, equals(0)); + }); + }); + + group('Given an invalid character inside the quoted-string,', () { + test('when tryReadQuotedString is called, ' + 'then it throws and rewinds the cursor.', () { + // 0x01 (SOH) is a control character, not allowed unescaped. + final s = HeaderScanner('"a\x01b"'); + + expect(s.tryReadQuotedString, throwsFormatException); + expect(s.position, equals(0)); + }); + }); + }); + + group('HeaderScanner.tryReadTokenOrQuotedString', () { + group('Given a token at the cursor,', () { + test('when tryReadTokenOrQuotedString is called, ' + 'then it reads the token.', () { + final s = HeaderScanner('gzip,deflate'); + + final value = s.tryReadTokenOrQuotedString(); + + expect(value, equals('gzip')); + }); + }); + + group('Given a quoted-string at the cursor,', () { + test('when tryReadTokenOrQuotedString is called, ' + 'then it reads and unescapes the quoted-string.', () { + final s = HeaderScanner('"hello world"'); + + final value = s.tryReadTokenOrQuotedString(); + + expect(value, equals('hello world')); + }); + }); + + group('Given neither a token nor a quoted-string,', () { + test('when tryReadTokenOrQuotedString is called, ' + 'then it returns null.', () { + final s = HeaderScanner(' '); + + expect(s.tryReadTokenOrQuotedString(), isNull); + }); + + test('when readTokenOrQuotedString is called, ' + 'then it throws a FormatException.', () { + final s = HeaderScanner(' '); + + expect(s.readTokenOrQuotedString, throwsFormatException); + }); + }); + }); + + group('HeaderScanner.indexOfTopLevel', () { + group('Given a separator outside any quoted-string,', () { + test('when indexOfTopLevel is called, ' + 'then it returns the index without advancing.', () { + final s = HeaderScanner('a,b'); + + expect(s.indexOfTopLevel($comma), equals(1)); + expect(s.position, equals(0)); + }); + }); + + group('Given a separator only inside a quoted-string,', () { + test('when indexOfTopLevel is called, ' + 'then the inner occurrence is skipped.', () { + final s = HeaderScanner('"a,b"c'); + + expect(s.indexOfTopLevel($comma), equals(-1)); + }); + + test('when there is also a top-level occurrence after, ' + 'then the top-level occurrence is returned.', () { + final s = HeaderScanner('"a,b",c'); + + expect(s.indexOfTopLevel($comma), equals(5)); + }); + }); + + group('Given a malformed quoted-string before any separator,', () { + test('when indexOfTopLevel is called, ' + 'then it throws and leaves the cursor unchanged.', () { + final s = HeaderScanner('"unterminated, more'); + + expect(() => s.indexOfTopLevel($comma), throwsFormatException); + expect(s.position, equals(0)); + }); + }); + }); + + group('HeaderScanner.splitTopLevel', () { + group('Given a simple comma-separated list,', () { + test('when splitTopLevel(comma) is iterated, ' + 'then each element is yielded with OWS trimmed.', () { + final s = HeaderScanner('gzip, deflate ,identity'); + + expect( + s.splitTopLevel($comma), + equals(['gzip', 'deflate', 'identity']), + ); + expect(s.atEnd, isTrue); + }); + }); + + group('Given a list with a quoted-string containing the separator,', () { + test('when splitTopLevel(semicolon) is iterated, ' + 'then the inner separator does not split the element.', () { + final s = HeaderScanner('for="[::1]:4711";desc="semi;colon"'); + + expect( + s.splitTopLevel($semicolon), + equals(['for="[::1]:4711"', 'desc="semi;colon"']), + ); + }); + }); + + group('Given a list with empty elements between separators,', () { + test('when splitTopLevel(comma) is iterated, ' + 'then empty elements are preserved as empty strings.', () { + final s = HeaderScanner('a,,b'); + + expect(s.splitTopLevel($comma), equals(['a', '', 'b'])); + }); + }); + + group('Given an empty source,', () { + test('when splitTopLevel(comma) is iterated, ' + 'then no elements are yielded.', () { + final s = HeaderScanner(''); + + expect(s.splitTopLevel($comma), isEmpty); + }); + }); + + group('Given a source with a trailing separator,', () { + test('when splitTopLevel(comma) is iterated, ' + 'then a trailing empty element is yielded.', () { + final s = HeaderScanner('a,b,'); + + expect(s.splitTopLevel($comma), equals(['a', 'b', ''])); + }); + }); + }); + + group('HeaderScanner end-to-end parameter parsing', () { + group('Given a parameter chain with quoted and bare values,', () { + test('when scanned semicolon-by-semicolon, ' + 'then names and values round-trip correctly.', () { + // Mimics what a Content-Disposition parser does. + final s = HeaderScanner(r'attachment; filename="a;b\".pdf"; size=1234'); + + final firstSemi = s.indexOfTopLevel($semicolon); + expect(firstSemi, equals(10)); + final type = s.source.substring(0, firstSemi); + expect(type, equals('attachment')); + s.position = firstSemi + 1; + s.skipOws(); + + expect(s.readToken(), equals('filename')); + s.expect($equals); + expect(s.peek(), equals($dquote)); + expect(s.readQuotedString(), equals(r'a;b".pdf')); + + s.expect($semicolon); + s.skipOws(); + expect(s.readToken(), equals('size')); + s.expect($equals); + expect(s.readToken(), equals('1234')); + expect(s.atEnd, isTrue); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/primitives/host_test.dart b/packages/relic_core/test/headers/typed/primitives/host_test.dart new file mode 100644 index 00000000..328b2f6b --- /dev/null +++ b/packages/relic_core/test/headers/typed/primitives/host_test.dart @@ -0,0 +1,335 @@ +import 'package:relic_core/src/headers/typed/primitives/host.dart'; +import 'package:test/test.dart'; + +void main() { + group('Host construction', () { + group('Given a non-empty host string,', () { + test('when Host is constructed with no port, ' + 'then port is null and host is preserved.', () { + final h = Host('example.com'); + + expect(h.host, equals('example.com')); + expect(h.port, isNull); + }); + + test('when Host is constructed with a port, ' + 'then both fields are preserved.', () { + final h = Host('example.com', 8080); + + expect(h.host, equals('example.com')); + expect(h.port, equals(8080)); + }); + }); + + group('Given an empty host string,', () { + test('when Host is constructed, ' + 'then it throws a FormatException.', () { + expect(() => Host(''), throwsFormatException); + }); + }); + + group('Given a host string containing URI brackets,', () { + test('when Host is constructed, ' + 'then it throws a FormatException.', () { + expect(() => Host('[::1]'), throwsFormatException); + expect(() => Host('abc[def'), throwsFormatException); + }); + }); + + group('Given a port outside 0-65535,', () { + test('when Host is constructed, ' + 'then it throws a FormatException.', () { + expect(() => Host('example.com', -1), throwsFormatException); + expect(() => Host('example.com', 65536), throwsFormatException); + }); + }); + + group('Given a host containing control characters or whitespace,', () { + test('when Host is constructed, ' + 'then it throws (blocks CR/LF injection).', () { + expect(() => Host('evil\r\nSet-Cookie: x=1'), throwsFormatException); + expect(() => Host('exa mple.com'), throwsFormatException); + expect(() => Host('host\x00name'), throwsFormatException); + }); + }); + + group('Given a host containing structural delimiters,', () { + test('when Host is constructed, ' + 'then it throws.', () { + expect(() => Host('user@host.com'), throwsFormatException); + expect(() => Host('foo/bar'), throwsFormatException); + expect(() => Host('a?b'), throwsFormatException); + }); + }); + + group('Given a normal reg-name (including versioned API hosts),', () { + test('when Host is constructed, ' + 'then it is accepted.', () { + expect(Host('v2.api.example.com').host, equals('v2.api.example.com')); + expect(Host('example.com').host, equals('example.com')); + }); + }); + + group('Given a colon-containing host that is not a valid IP-literal,', () { + test( + 'when Host is constructed, ' + 'then it throws (would otherwise encode to an unparseable [a:b]).', + () { + expect(() => Host('a:b'), throwsFormatException); + }, + ); + }); + + group('Given a bare IPv6 host (valid IP-literal),', () { + test('when Host is constructed, ' + 'then it is accepted and encodes bracketed.', () { + expect(Host('::1').encode(), equals('[::1]')); + expect(Host('2001:db8::1').encode(), equals('[2001:db8::1]')); + }); + }); + }); + + group('Host IPvFuture validation', () { + group('Given a valid IPvFuture literal,', () { + test('when parsed, ' + 'then it is accepted unbracketed and round-trips.', () { + final h = Host.parse('[v1.fe80]'); + + expect(h.host, equals('v1.fe80')); + }); + }); + + group('Given an IPvFuture with a non-hex version,', () { + test('when parsed, ' + 'then it throws.', () { + expect(() => Host.parse('[vG.x]'), throwsFormatException); + expect(() => Host.parse('[vZ.x]'), throwsFormatException); + }); + }); + + group('Given an IPvFuture with no version or empty tail,', () { + test('when parsed, ' + 'then it throws.', () { + expect(() => Host.parse('[v.foo]'), throwsFormatException); + expect(() => Host.parse('[v1.]'), throwsFormatException); + expect(() => Host.parse('[v]'), throwsFormatException); + }); + }); + + group('Given an IP-literal whose inner contains a control character,', () { + test('when parsed, ' + 'then it throws.', () { + expect(() => Host.parse('[v1.a\r\nX]'), throwsFormatException); + }); + }); + }); + + group('Host.fromUri', () { + group('Given a URI with an explicit port,', () { + test('when Host.fromUri is called, ' + 'then the port is preserved.', () { + final h = Host.fromUri(Uri.parse('http://example.com:8080/path')); + + expect(h.host, equals('example.com')); + expect(h.port, equals(8080)); + }); + }); + + group('Given a URI with a default port (no explicit port),', () { + test('when Host.fromUri is called, ' + 'then port is null rather than 80/443.', () { + final h1 = Host.fromUri(Uri.parse('http://example.com/')); + final h2 = Host.fromUri(Uri.parse('https://example.com/')); + + expect(h1.port, isNull); + expect(h2.port, isNull); + }); + }); + + group('Given a URI with an IPv6 host,', () { + test('when Host.fromUri is called, ' + 'then host is stored unbracketed.', () { + final h = Host.fromUri(Uri.parse('http://[::1]:8080/')); + + expect(h.host, equals('::1')); + expect(h.port, equals(8080)); + }); + }); + + group('Given a URI with no host,', () { + test('when Host.fromUri is called, ' + 'then it throws a FormatException.', () { + expect( + () => Host.fromUri(Uri.parse('/relative/path')), + throwsFormatException, + ); + }); + }); + }); + + group('Host.parse', () { + group('Given a bare reg-name,', () { + test('when parsed, ' + 'then host is the input and port is null.', () { + final h = Host.parse('example.com'); + + expect(h.host, equals('example.com')); + expect(h.port, isNull); + }); + }); + + group('Given a reg-name with port,', () { + test('when parsed, ' + 'then host and port are split correctly.', () { + final h = Host.parse('example.com:8080'); + + expect(h.host, equals('example.com')); + expect(h.port, equals(8080)); + }); + }); + + group('Given an IPv4 with port,', () { + test('when parsed, ' + 'then host and port are split correctly.', () { + final h = Host.parse('192.0.2.1:80'); + + expect(h.host, equals('192.0.2.1')); + expect(h.port, equals(80)); + }); + }); + + group('Given a bracketed IPv6 with port,', () { + test('when parsed, ' + 'then host is unbracketed and port is preserved.', () { + final h = Host.parse('[::1]:8080'); + + expect(h.host, equals('::1')); + expect(h.port, equals(8080)); + }); + }); + + group('Given a bracketed IPv6 with no port,', () { + test('when parsed, ' + 'then host is unbracketed and port is null.', () { + final h = Host.parse('[2001:db8::1]'); + + expect(h.host, equals('2001:db8::1')); + expect(h.port, isNull); + }); + }); + + group('Given an unbracketed IPv6 literal,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => Host.parse('::1:80'), throwsFormatException); + expect(() => Host.parse('2001:db8::1'), throwsFormatException); + }); + }); + + group('Given an unterminated IP-literal,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => Host.parse('[::1'), throwsFormatException); + }); + }); + + group('Given an IP-literal followed by garbage,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => Host.parse('[::1]foo'), throwsFormatException); + }); + }); + + group('Given a bracketed literal that is not a valid IPv6 address,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => Host.parse('[zzz]'), throwsFormatException); + expect(() => Host.parse('[gggg::1]'), throwsFormatException); + expect(() => Host.parse('[1.2.3.4]'), throwsFormatException); + }); + }); + + group('Given a port that is non-numeric,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => Host.parse('example.com:abc'), throwsFormatException); + }); + }); + + group('Given a port that exceeds 65535,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => Host.parse('example.com:65536'), throwsFormatException); + expect(() => Host.parse('example.com:999999'), throwsFormatException); + }); + }); + + group('Given an empty port after colon,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => Host.parse('example.com:'), throwsFormatException); + }); + }); + }); + + group('Host.encode', () { + group('Given a reg-name,', () { + test('when encoded, ' + 'then the wire form has no brackets.', () { + expect(Host('example.com').encode(), equals('example.com')); + expect(Host('example.com', 8080).encode(), equals('example.com:8080')); + }); + }); + + group('Given an IPv6 host,', () { + test('when encoded, ' + 'then the wire form is bracketed.', () { + expect(Host('::1').encode(), equals('[::1]')); + expect(Host('::1', 8080).encode(), equals('[::1]:8080')); + expect(Host('2001:db8::1', 443).encode(), equals('[2001:db8::1]:443')); + }); + }); + + group('Given a Host round-tripped through parse,', () { + test('when re-encoded, ' + 'then the wire form matches the input.', () { + for (final input in const [ + 'example.com', + 'example.com:8080', + '192.0.2.1', + '192.0.2.1:80', + '[::1]', + '[::1]:8080', + '[2001:db8::1]:443', + ]) { + expect(Host.parse(input).encode(), equals(input)); + } + }); + }); + }); + + group('Host equality and hashing', () { + group('Given two Hosts that differ only in ASCII letter case,', () { + test('when compared with ==, ' + 'then they are equal.', () { + expect(Host('Example.com') == Host('example.COM'), isTrue); + }); + + test('when their hashCodes are compared, ' + 'then they are equal.', () { + expect( + Host('Example.com').hashCode, + equals(Host('example.COM').hashCode), + ); + }); + }); + + group('Given two Hosts that differ only in port,', () { + test('when compared with ==, ' + 'then they are not equal.', () { + expect(Host('example.com') == Host('example.com', 80), isFalse); + expect(Host('example.com', 80) == Host('example.com', 443), isFalse); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/primitives/language_tag_test.dart b/packages/relic_core/test/headers/typed/primitives/language_tag_test.dart new file mode 100644 index 00000000..ebb086c1 --- /dev/null +++ b/packages/relic_core/test/headers/typed/primitives/language_tag_test.dart @@ -0,0 +1,230 @@ +import 'package:relic_core/src/headers/typed/primitives/language_tag.dart'; +import 'package:test/test.dart'; + +void main() { + group('LanguageTag.parse - basic langtag shapes', () { + group('Given a simple 2-letter language,', () { + test('when parsed, ' + 'then the subtag is lowercased.', () { + expect(LanguageTag.parse('EN').encode(), equals('en')); + expect(LanguageTag.parse('en').encode(), equals('en')); + }); + }); + + group('Given a language with ALPHA region,', () { + test('when parsed, ' + 'then language is lowercased and region uppercased.', () { + expect(LanguageTag.parse('en-us').encode(), equals('en-US')); + expect(LanguageTag.parse('EN-US').encode(), equals('en-US')); + expect(LanguageTag.parse('en-US').encode(), equals('en-US')); + }); + }); + + group('Given a language with a digits region (UN M.49),', () { + test('when parsed, ' + 'then the region digits are preserved.', () { + expect(LanguageTag.parse('es-419').encode(), equals('es-419')); + }); + }); + + group('Given a 3-letter ISO 639 language,', () { + test('when parsed, ' + 'then it is accepted.', () { + expect(LanguageTag.parse('cmn').encode(), equals('cmn')); + }); + }); + + group('Given an extlang,', () { + test('when parsed, ' + 'then the extlang subtag follows the language.', () { + expect( + LanguageTag.parse('zh-cmn-Hans-CN').encode(), + equals('zh-cmn-Hans-CN'), + ); + }); + }); + + group('Given a language with script and region,', () { + test('when parsed, ' + 'then the script is title-cased.', () { + expect(LanguageTag.parse('zh-hant-tw').encode(), equals('zh-Hant-TW')); + expect(LanguageTag.parse('zh-HANT-TW').encode(), equals('zh-Hant-TW')); + }); + }); + + group('Given a language with a variant subtag,', () { + test('when parsed, ' + 'then the variant is lowercased and preserved.', () { + expect(LanguageTag.parse('de-DE-1996').encode(), equals('de-DE-1996')); + expect( + LanguageTag.parse('sl-IT-nedis').encode(), + equals('sl-IT-nedis'), + ); + }); + }); + + group('Given a language with an extension,', () { + test('when parsed, ' + 'then the singleton and subtags are preserved.', () { + expect( + LanguageTag.parse('de-DE-u-co-phonebk').encode(), + equals('de-DE-u-co-phonebk'), + ); + expect( + LanguageTag.parse('en-a-bbb-x-private').encode(), + equals('en-a-bbb-x-private'), + ); + }); + }); + }); + + group('LanguageTag.parse - private-use', () { + group('Given a fully private-use tag,', () { + test('when parsed, ' + 'then it is accepted and lowercased.', () { + expect(LanguageTag.parse('x-foo').encode(), equals('x-foo')); + expect(LanguageTag.parse('X-FOO-BAR').encode(), equals('x-foo-bar')); + }); + }); + + group('Given a private-use prefix with no subtags,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => LanguageTag.parse('x'), throwsFormatException); + }); + }); + }); + + group('LanguageTag.parse - grandfathered', () { + group('Given an irregular grandfathered tag,', () { + test('when parsed, ' + 'then it is accepted as-is in lowercase.', () { + expect(LanguageTag.parse('i-klingon').encode(), equals('i-klingon')); + expect(LanguageTag.parse('I-Klingon').encode(), equals('i-klingon')); + expect(LanguageTag.parse('en-gb-oed').encode(), equals('en-gb-oed')); + }); + }); + + group('Given a non-irregular "i-..." tag,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => LanguageTag.parse('i-unknownlang'), throwsFormatException); + }); + }); + }); + + group('LanguageTag.parse - rejection cases', () { + group('Given an empty string,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => LanguageTag.parse(''), throwsFormatException); + }); + }); + + group('Given a tag with a leading hyphen,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => LanguageTag.parse('-en'), throwsFormatException); + }); + }); + + group('Given a tag with a trailing hyphen,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => LanguageTag.parse('en-'), throwsFormatException); + }); + }); + + group('Given a tag with double hyphens,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => LanguageTag.parse('en--US'), throwsFormatException); + }); + }); + + group('Given a subtag longer than 8 characters,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => LanguageTag.parse('toolongtag'), throwsFormatException); + }); + }); + + group('Given a non-alphanumeric character in a subtag,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => LanguageTag.parse('en_US'), throwsFormatException); + expect(() => LanguageTag.parse('en-U!S'), throwsFormatException); + }); + }); + + group('Given an extension singleton with no subtags,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => LanguageTag.parse('en-a'), throwsFormatException); + expect(() => LanguageTag.parse('en-a-b'), throwsFormatException); + }); + }); + }); + + group('LanguageTag equality and round-trip', () { + group('Given two tags differing only in case,', () { + test('when compared, ' + 'then they are equal.', () { + expect(LanguageTag.parse('EN-US'), equals(LanguageTag.parse('en-us'))); + expect( + LanguageTag.parse('zh-HANT-TW'), + equals(LanguageTag.parse('zh-hant-tw')), + ); + }); + + test('when hashed, ' + 'then their hash codes are equal.', () { + expect( + LanguageTag.parse('EN-US').hashCode, + equals(LanguageTag.parse('en-us').hashCode), + ); + }); + }); + + group('Given a parsed tag,', () { + test('when re-parsed from its encoded form, ' + 'then the result is equal to the original.', () { + for (final input in const [ + 'en', + 'en-US', + 'es-419', + 'zh-Hant-TW', + 'zh-cmn-Hans-CN', + 'de-DE-1996', + 'sl-IT-nedis', + 'de-DE-u-co-phonebk', + 'en-a-bbb-x-private', + 'x-foo-bar', + 'i-klingon', + ]) { + final parsed = LanguageTag.parse(input); + expect(LanguageTag.parse(parsed.encode()), equals(parsed)); + } + }); + }); + }); + + group('LanguageTag.parse - duplicate subtags', () { + group('Given a repeated variant subtag,', () { + test('when parsed, ' + 'then it throws (RFC 5646 2.2.5).', () { + expect(() => LanguageTag.parse('en-1996-1996'), throwsFormatException); + }); + }); + + group('Given a repeated extension singleton,', () { + test('when parsed, ' + 'then it throws (RFC 5646 2.2.6).', () { + expect( + () => LanguageTag.parse('en-a-foo-a-bar'), + throwsFormatException, + ); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/primitives/origin_test.dart b/packages/relic_core/test/headers/typed/primitives/origin_test.dart new file mode 100644 index 00000000..56b835e9 --- /dev/null +++ b/packages/relic_core/test/headers/typed/primitives/origin_test.dart @@ -0,0 +1,259 @@ +import 'package:relic_core/src/headers/typed/primitives/host.dart'; +import 'package:relic_core/src/headers/typed/primitives/origin.dart'; +import 'package:test/test.dart'; + +void main() { + group('Origin.parse', () { + group('Given the literal string "null",', () { + test('when parsed, ' + 'then the OpaqueOrigin sentinel is returned.', () { + expect(Origin.parse('null'), same(OpaqueOrigin.instance)); + }); + }); + + group('Given a scheme://host origin,', () { + test('when parsed, ' + 'then a TupleOrigin with normalized scheme is returned.', () { + final o = Origin.parse('https://example.com') as TupleOrigin; + + expect(o.scheme, equals('https')); + expect(o.host.host, equals('example.com')); + expect(o.host.port, isNull); + }); + + test('when the scheme has mixed case, ' + 'then it is lowercased.', () { + final o = Origin.parse('HTTPS://example.com') as TupleOrigin; + + expect(o.scheme, equals('https')); + }); + }); + + group('Given a scheme://host:port origin,', () { + test('when parsed, ' + 'then the port is preserved.', () { + final o = Origin.parse('http://example.com:8080') as TupleOrigin; + + expect(o.host.port, equals(8080)); + }); + }); + + group('Given a scheme://[ipv6]:port origin,', () { + test('when parsed, ' + 'then the IPv6 host is unbracketed and port preserved.', () { + final o = Origin.parse('http://[::1]:8080') as TupleOrigin; + + expect(o.host.host, equals('::1')); + expect(o.host.port, equals(8080)); + }); + }); + + group('Given an origin with a trailing slash,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect( + () => Origin.parse('https://example.com/'), + throwsFormatException, + ); + }); + }); + + group('Given an origin with a path,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect( + () => Origin.parse('https://example.com/path'), + throwsFormatException, + ); + }); + }); + + group('Given an origin with a query or fragment,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect( + () => Origin.parse('https://example.com?q=1'), + throwsFormatException, + ); + expect( + () => Origin.parse('https://example.com#frag'), + throwsFormatException, + ); + }); + }); + + group('Given an input missing "://" between scheme and host,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => Origin.parse('https:example.com'), throwsFormatException); + expect(() => Origin.parse('example.com'), throwsFormatException); + }); + }); + + group('Given an empty host after "://",', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => Origin.parse('https://'), throwsFormatException); + }); + }); + + group('Given an origin that includes userinfo,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect( + () => Origin.parse('http://user:pass@example.com'), + throwsFormatException, + ); + expect( + () => Origin.parse('http://user@example.com'), + throwsFormatException, + ); + }); + }); + + group('Given an origin containing whitespace or a control character,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect( + () => Origin.parse('http://exa mple.com'), + throwsFormatException, + ); + expect( + () => Origin.parse('http://example.com\r\nevil'), + throwsFormatException, + ); + }); + }); + }); + + group('TupleOrigin construction', () { + group('Given an invalid scheme,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect( + () => TupleOrigin(scheme: '', host: Host('example.com')), + throwsFormatException, + ); + expect( + () => TupleOrigin(scheme: '1http', host: Host('example.com')), + throwsFormatException, + ); + expect( + () => TupleOrigin(scheme: 'ht tp', host: Host('example.com')), + throwsFormatException, + ); + }); + }); + + group('Given a valid scheme,', () { + test('when constructed, ' + 'then the scheme is stored in lowercase.', () { + final o = TupleOrigin(scheme: 'HTTPS', host: Host('example.com')); + + expect(o.scheme, equals('https')); + }); + + test('when constructed with allowed RFC 3986 specials, ' + 'then it is accepted.', () { + final o = TupleOrigin(scheme: 'a+b-c.d', host: Host('example.com')); + + expect(o.scheme, equals('a+b-c.d')); + }); + }); + }); + + group('Origin.encode', () { + group('Given an OpaqueOrigin,', () { + test('when encoded, ' + 'then the wire value is "null".', () { + expect(OpaqueOrigin.instance.encode(), equals('null')); + }); + }); + + group('Given a TupleOrigin without port,', () { + test('when encoded, ' + 'then no port suffix appears.', () { + final o = TupleOrigin(scheme: 'https', host: Host('example.com')); + + expect(o.encode(), equals('https://example.com')); + }); + }); + + group('Given a TupleOrigin with port,', () { + test('when encoded, ' + 'then the port suffix is included.', () { + final o = TupleOrigin(scheme: 'http', host: Host('example.com', 8080)); + + expect(o.encode(), equals('http://example.com:8080')); + }); + }); + + group('Given a TupleOrigin with an IPv6 host,', () { + test('when encoded, ' + 'then the host appears bracketed.', () { + final o = TupleOrigin(scheme: 'http', host: Host('::1', 8080)); + + expect(o.encode(), equals('http://[::1]:8080')); + }); + }); + + group('Given an Origin round-tripped through parse,', () { + test('when re-encoded, ' + 'then the wire form matches the input.', () { + for (final input in const [ + 'null', + 'https://example.com', + 'http://example.com:8080', + 'http://[::1]', + 'http://[::1]:8080', + 'https://[2001:db8::1]:443', + ]) { + expect(Origin.parse(input).encode(), equals(input)); + } + }); + }); + }); + + group('Origin equality', () { + group('Given the OpaqueOrigin sentinel,', () { + test('when used as Set elements, ' + 'then duplicates collapse to one.', () { + final a = TupleOrigin(scheme: 'https', host: Host('example.com')); + final b = TupleOrigin(scheme: 'HTTPS', host: Host('Example.com')); + final s = {a, b}; + + expect(s, hasLength(1)); + }); + + test('when compared with itself, ' + 'then it is equal and shares its hashCode.', () { + expect(OpaqueOrigin.instance, equals(OpaqueOrigin.instance)); + expect( + OpaqueOrigin.instance.hashCode, + equals(OpaqueOrigin.instance.hashCode), + ); + }); + }); + + group('Given two TupleOrigins with same parts,', () { + test('when compared, ' + 'then they are equal.', () { + final a = TupleOrigin(scheme: 'https', host: Host('example.com')); + final b = TupleOrigin(scheme: 'HTTPS', host: Host('Example.com')); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('Given a TupleOrigin and an OpaqueOrigin,', () { + test('when compared, ' + 'then they are not equal.', () { + final t = TupleOrigin(scheme: 'https', host: Host('example.com')); + + expect(t == OpaqueOrigin.instance, isFalse); + expect(OpaqueOrigin.instance == t, isFalse); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/primitives/parameter_value_test.dart b/packages/relic_core/test/headers/typed/primitives/parameter_value_test.dart new file mode 100644 index 00000000..59f29da3 --- /dev/null +++ b/packages/relic_core/test/headers/typed/primitives/parameter_value_test.dart @@ -0,0 +1,149 @@ +import 'package:relic_core/src/headers/typed/primitives/header_scanner.dart'; +import 'package:relic_core/src/headers/typed/primitives/parameter_value.dart'; +import 'package:test/test.dart'; + +void main() { + group('ParameterValue construction', () { + group('Given a token-shaped string,', () { + test('when constructed, ' + 'then value is preserved.', () { + expect(ParameterValue('gzip').value, equals('gzip')); + expect(ParameterValue('X-Foo').value, equals('X-Foo')); + }); + }); + + group('Given a string containing spaces or specials,', () { + test('when constructed, ' + 'then it is accepted (output will use quoted-string form).', () { + expect(ParameterValue('hello world').value, equals('hello world')); + expect(ParameterValue('semi;colon').value, equals('semi;colon')); + expect(ParameterValue('with "quotes"').value, equals('with "quotes"')); + }); + }); + + group('Given the empty string,', () { + test('when constructed, ' + 'then it is accepted and encodes as empty quoted-string.', () { + expect(ParameterValue('').encode(), equals('""')); + }); + }); + + group('Given a string containing control characters,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect(() => ParameterValue('a\nb'), throwsFormatException); + expect(() => ParameterValue('a\x01b'), throwsFormatException); + expect(() => ParameterValue('a\x7fb'), throwsFormatException); + }); + }); + + group('Given a string containing code units above 0xFF,', () { + test('when constructed, ' + 'then it throws a FormatException.', () { + expect(() => ParameterValue('café'), returnsNormally); + expect(() => ParameterValue('Ā'), throwsFormatException); + }); + }); + }); + + group('ParameterValue.encode', () { + group('Given a value that is a valid token,', () { + test('when encoded, ' + 'then the bare-token form is used.', () { + expect(ParameterValue('gzip').encode(), equals('gzip')); + expect(ParameterValue('X-Foo').encode(), equals('X-Foo')); + }); + }); + + group('Given a value that is not a valid token,', () { + test('when encoded, ' + 'then the quoted-string form is used.', () { + expect(ParameterValue('hello world').encode(), equals('"hello world"')); + expect(ParameterValue('with/slash').encode(), equals('"with/slash"')); + }); + }); + + group('Given a value containing characters that need escaping,', () { + test('when encoded, ' + 'then DQUOTE and backslash are escaped with quoted-pair.', () { + expect( + ParameterValue(r'with "quote"').encode(), + equals(r'"with \"quote\""'), + ); + expect( + ParameterValue(r'with\backslash').encode(), + equals(r'"with\\backslash"'), + ); + }); + }); + + group('Given a parameter value round-tripped through scanner,', () { + test('when re-parsed via ParameterValue.read, ' + 'then the value is preserved.', () { + for (final raw in const [ + 'gzip', + 'hello world', + r'with "quote"', + r'with\backslash', + '', + ]) { + final encoded = ParameterValue(raw).encode(); + final scanner = HeaderScanner(encoded); + final parsed = ParameterValue.read(scanner); + + expect(parsed.value, equals(raw)); + expect(scanner.atEnd, isTrue); + } + }); + }); + }); + + group('ParameterValue.read', () { + group('Given a scanner positioned at a token,', () { + test('when read is called, ' + 'then the token bytes form the value.', () { + final s = HeaderScanner('gzip'); + + expect(ParameterValue.read(s).value, equals('gzip')); + }); + }); + + group('Given a scanner positioned at a quoted-string with escapes,', () { + test('when read is called, ' + 'then the value is unescaped.', () { + final s = HeaderScanner(r'"a\"b\\c"'); + + expect(ParameterValue.read(s).value, equals(r'a"b\c')); + }); + }); + + group('Given a scanner positioned at neither a token nor a quote,', () { + test('when read is called, ' + 'then it throws a FormatException.', () { + final s = HeaderScanner(' leading-space'); + + expect(() => ParameterValue.read(s), throwsFormatException); + }); + }); + }); + + group('ParameterValue equality', () { + group('Given two ParameterValues with identical content,', () { + test('when compared, ' + 'then they are equal and share hashCode.', () { + final a = ParameterValue('hello world'); + final b = ParameterValue('hello world'); + + expect(a, equals(b)); + expect(a.hashCode, equals(b.hashCode)); + }); + }); + + group('Given two ParameterValues that differ only in case,', () { + test('when compared, ' + 'then they are not equal (case-sensitive value comparison).', () { + expect(ParameterValue('gzip') == ParameterValue('GZIP'), isFalse); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/primitives/token_test.dart b/packages/relic_core/test/headers/typed/primitives/token_test.dart new file mode 100644 index 00000000..8f42d6f0 --- /dev/null +++ b/packages/relic_core/test/headers/typed/primitives/token_test.dart @@ -0,0 +1,219 @@ +import 'package:relic_core/src/headers/typed/primitives/token.dart'; +import 'package:test/test.dart'; + +void main() { + group('Token validation', () { + group('Given an empty string,', () { + test('when checked with Token.isValid, ' + 'then it returns false.', () { + expect(Token.isValid(''), isFalse); + }); + + test('when passed to Token.validate, ' + 'then it throws a FormatException.', () { + expect(() => Token.validate(''), throwsFormatException); + }); + }); + + group('Given a valid tchar character,', () { + test('when Token.isValid is called for each tchar special, ' + 'then every one returns true.', () { + const specials = "!#\$%&'*+-.^_`|~"; + for (final c in specials.split('')) { + expect(Token.isValid(c), isTrue, reason: 'tchar special $c'); + } + }); + }); + + group('Given a mixed-case alphanumeric token string,', () { + test('when checked with Token.isValid, ' + 'then it returns true.', () { + expect(Token.isValid('Bearer'), isTrue); + expect(Token.isValid('X-Custom-Header'), isTrue); + expect(Token.isValid('text-encoding-v2.0'), isTrue); + expect(Token.isValid('MD5'), isTrue); + }); + + test('when passed to Token.validate, ' + 'then the original string is returned unchanged.', () { + expect(Token.validate('gzip'), equals('gzip')); + expect(Token.validate('GZIP'), equals('GZIP')); + expect(Token.validate('X-Foo'), equals('X-Foo')); + }); + }); + + group('Given a string containing whitespace,', () { + test('when checked with Token.isValid, ' + 'then it returns false.', () { + expect(Token.isValid('a b'), isFalse); + expect(Token.isValid(' '), isFalse); + expect(Token.isValid('leading-space '), isFalse); + }); + + test('when passed to Token.validate, ' + 'then it throws a FormatException.', () { + expect(() => Token.validate('has space'), throwsFormatException); + }); + }); + + group('Given a string containing RFC 9110 separator characters,', () { + test('when checked with Token.isValid, ' + 'then it returns false for each separator.', () { + for (final c in '()<>@,;:\\"/[]?={}'.split('')) { + expect( + Token.isValid('valid${c}part'), + isFalse, + reason: 'separator $c', + ); + } + }); + + test('when passed to Token.validate, ' + 'then it throws a FormatException.', () { + expect(() => Token.validate('semi;colon'), throwsFormatException); + }); + }); + + group('Given a string containing non-ASCII characters,', () { + test('when checked with Token.isValid, ' + 'then it returns false.', () { + expect(Token.isValid('café'), isFalse); + expect(Token.isValid('ÿ'), isFalse); + }); + }); + + group('Given a string containing control characters,', () { + test('when checked with Token.isValid, ' + 'then it returns false.', () { + expect(Token.isValid('a\tb'), isFalse); + expect(Token.isValid('a\nb'), isFalse); + expect(Token.isValid('a\x7fb'), isFalse); + }); + }); + }); + + group('Token.equals and Token.hashFor', () { + group('Given two TokenValues differing only in ASCII letter case,', () { + test('when compared with Token.equals, ' + 'then they are reported equal.', () { + expect(Token.equals(TokenValue('gzip'), TokenValue('GZIP')), isTrue); + expect(Token.equals(TokenValue('X-Foo'), TokenValue('x-foo')), isTrue); + }); + + test('when hashed with Token.hashFor, ' + 'then both produce the same hash.', () { + expect( + Token.hashFor(TokenValue('Bearer')), + equals(Token.hashFor(TokenValue('BEARER'))), + ); + }); + }); + + group('Given two TokenValues with different lengths,', () { + test('when compared with Token.equals, ' + 'then they are reported unequal.', () { + expect(Token.equals(TokenValue('gzip'), TokenValue('gzipx')), isFalse); + }); + }); + + group('Given two TokenValues of the same length with different bytes,', () { + test('when compared with Token.equals, ' + 'then they are reported unequal.', () { + expect(Token.equals(TokenValue('gzip'), TokenValue('gzpi')), isFalse); + }); + }); + + group('Given two TokenValues mixing letters and tchar specials,', () { + test('when compared with Token.equals, ' + 'then only ASCII letters are case-folded.', () { + expect(Token.equals(TokenValue('a^b'), TokenValue('A^B')), isTrue); + }); + }); + }); + + group('TokenValue construction', () { + group('Given an invalid token string,', () { + test('when passed to the TokenValue constructor, ' + 'then it throws a FormatException.', () { + expect(() => TokenValue(''), throwsFormatException); + expect(() => TokenValue('has space'), throwsFormatException); + }); + }); + + group('Given a valid token string,', () { + test('when passed to the TokenValue constructor, ' + 'then the original casing is preserved on the value field.', () { + expect(TokenValue('GZIP').value, equals('GZIP')); + expect(TokenValue('X-Foo').value, equals('X-Foo')); + }); + + test('when toString is called, ' + 'then it returns the wire value.', () { + expect(TokenValue('gzip').toString(), equals('gzip')); + expect(TokenValue('X-Foo').toString(), equals('X-Foo')); + }); + }); + }); + + group('TokenValue equality and hashing', () { + group('Given two TokenValues differing only in ASCII letter case,', () { + test('when compared with ==, ' + 'then they are equal.', () { + expect(TokenValue('gzip') == TokenValue('GZIP'), isTrue); + }); + + test('when their hashCodes are compared, ' + 'then they are equal.', () { + expect( + TokenValue('gzip').hashCode, + equals(TokenValue('GZIP').hashCode), + ); + }); + + test('when placed into a Set, ' + 'then the Set treats them as one element.', () { + final set = { + TokenValue('gzip'), + TokenValue('GZIP'), + TokenValue('deflate'), + }; + + expect(set, hasLength(2)); + expect(set.contains(TokenValue('gZiP')), isTrue); + }); + }); + + group('Given two TokenValues with different wire values,', () { + test('when compared with ==, ' + 'then they are not equal.', () { + expect(TokenValue('gzip') == TokenValue('deflate'), isFalse); + }); + }); + + group('Given a TokenValue and another Token of equal value,', () { + test('when compared with ==, ' + 'then they are not equal.', () { + final tv = TokenValue('foo'); + final other = _EnumLikeToken('foo'); + + // ignore: unrelated_type_equality_checks + expect(tv == other, isFalse); + }); + + test('when compared with Token.equals, ' + 'then they are reported equal by wire value.', () { + final tv = TokenValue('foo'); + final other = _EnumLikeToken('foo'); + + expect(Token.equals(tv, other), isTrue); + }); + }); + }); +} + +/// Stands in for an enum value that `implements Token` with identity equality. +class _EnumLikeToken implements Token { + @override + final String value; + const _EnumLikeToken(this.value); +} diff --git a/packages/relic_core/test/headers/typed/range_header_test.dart b/packages/relic_core/test/headers/typed/range_header_test.dart new file mode 100644 index 00000000..8a7126ec --- /dev/null +++ b/packages/relic_core/test/headers/typed/range_header_test.dart @@ -0,0 +1,43 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('Range construction', () { + group('Given a range with start greater than end,', () { + test('when constructed, ' + 'then it is accepted; the consumer decides satisfiability.', () { + final range = Range(start: 100, end: 50); + + expect(range.start, equals(100)); + expect(range.end, equals(50)); + }); + }); + + group('Given a range with start equal to end,', () { + test('when constructed, ' + 'then it is accepted.', () { + expect(Range(start: 5, end: 5).start, equals(5)); + }); + }); + + group('Given an open-ended or suffix range,', () { + test('when constructed, ' + 'then it is accepted.', () { + expect(Range(start: 100).end, isNull); + expect(Range(end: 500).start, isNull); + }); + }); + }); + + group('RangeHeader.parse', () { + group('Given an inverted byte range,', () { + test('when parsed, ' + 'then the bounds are preserved (not rejected).', () { + final header = RangeHeader.parse('bytes=100-50'); + + expect(header.ranges.single.start, equals(100)); + expect(header.ranges.single.end, equals(50)); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/set_cookie_header_test.dart b/packages/relic_core/test/headers/typed/set_cookie_header_test.dart new file mode 100644 index 00000000..99fe8c3c --- /dev/null +++ b/packages/relic_core/test/headers/typed/set_cookie_header_test.dart @@ -0,0 +1,139 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('SetCookieHeader Domain attribute (regression for #355)', () { + group('Given a SetCookieHeader with a Host domain,', () { + test('when encoded via codec, ' + 'then Domain= carries only the hostname (no scheme or slashes).', () { + final cookie = SetCookieHeader( + name: 'sid', + value: 'abc', + domain: Host('abc.co'), + ); + + expect( + SetCookieHeader.codec.encode(cookie).single, + equals('sid=abc; Domain=abc.co'), + ); + }); + + test('when constructed with an IPv6 host, ' + 'then Domain= encodes it bracketed.', () { + final cookie = SetCookieHeader( + name: 'sid', + value: 'abc', + domain: Host('::1'), + ); + + expect( + SetCookieHeader.codec.encode(cookie).single, + contains('Domain=[::1]'), + ); + }); + }); + + group('Given a Set-Cookie wire value with a Domain attribute,', () { + test('when parsed and re-encoded, ' + 'then the Domain hostname round-trips.', () { + final parsed = SetCookieHeader.parse( + 'sid=abc; Domain=example.com; Path=/api', + ); + + expect(parsed.domain?.host, equals('example.com')); + expect(parsed.path, equals('/api')); + expect( + SetCookieHeader.codec.encode(parsed).single, + contains('Domain=example.com'), + ); + }); + }); + + group('Given a Host domain that carries a port,', () { + test('when used to construct a SetCookieHeader, ' + 'then it throws (RFC 6265 5.2.3 forbids a port in Domain).', () { + expect( + () => SetCookieHeader( + name: 'sid', + value: 'abc', + domain: Host('example.com', 8080), + ), + throwsFormatException, + ); + }); + }); + }); + + group('SetCookieHeader parser hardening', () { + group('Given a cookie name that contains an attribute keyword,', () { + test('when parsed, ' + 'then the first pair is the cookie, not the attribute.', () { + final parsed = SetCookieHeader.parse('sessionhttponly=xyz'); + + expect(parsed.name, equals('sessionhttponly')); + expect(parsed.value, equals('xyz')); + expect(parsed.httpOnly, isFalse); + }); + }); + + group('Given a cookie value that contains an "=",', () { + test('when parsed, ' + 'then only the first "=" splits name from value.', () { + final parsed = SetCookieHeader.parse('token=a=b=c'); + + expect(parsed.name, equals('token')); + expect(parsed.value, equals('a=b=c')); + }); + }); + + group('Given a Path attribute value that contains an "=",', () { + test('when parsed, ' + 'then the full path after the first "=" is preserved.', () { + final parsed = SetCookieHeader.parse('sid=x; Path=/foo=bar'); + + expect(parsed.path, equals('/foo=bar')); + }); + }); + + group('Given a cookie name equal to an attribute label,', () { + test('when encoded, ' + 'then the cookie-pair is not collapsed with the attribute.', () { + final cookie = SetCookieHeader(name: 'Path', value: '/foo', path: '/x'); + + final encoded = SetCookieHeader.codec.encode(cookie).single; + expect(encoded, contains('Path=/foo')); + expect(encoded, contains('Path=/x')); + expect(encoded, equals('Path=/foo; Path=/x')); + }); + }); + + group('Given a path containing a control character,', () { + test('when used to construct a SetCookieHeader, ' + 'then it throws to prevent header injection.', () { + expect( + () => SetCookieHeader( + name: 'sid', + value: 'x', + path: '/foo\r\nSet-Cookie: evil=1', + ), + throwsFormatException, + ); + }); + }); + + group('Given an empty cookie name (the "=value" quirk),', () { + test('when encoded, ' + 'then the cookie-pair is still emitted and round-trips.', () { + final cookie = SetCookieHeader(name: '', value: 'abc123', secure: true); + + final encoded = SetCookieHeader.codec.encode(cookie).single; + expect(encoded, equals('=abc123; Secure')); + + final reparsed = SetCookieHeader.parse(encoded); + expect(reparsed.name, isEmpty); + expect(reparsed.value, equals('abc123')); + expect(reparsed.secure, isTrue); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/strict_transport_security_header_test.dart b/packages/relic_core/test/headers/typed/strict_transport_security_header_test.dart new file mode 100644 index 00000000..c1da66f4 --- /dev/null +++ b/packages/relic_core/test/headers/typed/strict_transport_security_header_test.dart @@ -0,0 +1,50 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('StrictTransportSecurityHeader.parse', () { + group('Given directives in non-canonical case,', () { + test('when parsed, ' + 'then they are matched case-insensitively.', () { + final hsts = StrictTransportSecurityHeader.parse( + 'Max-Age=31536000; IncludeSubDomains; Preload', + ); + + expect(hsts.maxAge, equals(31536000)); + expect(hsts.includeSubDomains, isTrue); + expect(hsts.preload, isTrue); + }); + }); + + group('Given a quoted max-age value,', () { + test('when parsed, ' + 'then the quotes are stripped.', () { + final hsts = StrictTransportSecurityHeader.parse('max-age="0"'); + + expect(hsts.maxAge, equals(0)); + }); + }); + + group('Given a negative max-age,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect( + () => StrictTransportSecurityHeader.parse('max-age=-1'), + throwsFormatException, + ); + }); + }); + + group('Given OWS around the directive "=",', () { + test('when parsed, ' + 'then max-age is still recognized.', () { + final hsts = StrictTransportSecurityHeader.parse( + 'MAX-AGE = "31536000" ; includeSubDomains', + ); + + expect(hsts.maxAge, equals(31536000)); + expect(hsts.includeSubDomains, isTrue); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/transfer_encoding_header_test.dart b/packages/relic_core/test/headers/typed/transfer_encoding_header_test.dart new file mode 100644 index 00000000..cc4c4339 --- /dev/null +++ b/packages/relic_core/test/headers/typed/transfer_encoding_header_test.dart @@ -0,0 +1,109 @@ +import 'package:relic_core/relic_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('TransferEncodingHeader.parse', () { + group('Given a valid multi-coding value,', () { + test('when parsed, ' + 'then the encodings are preserved in order.', () { + final header = TransferEncodingHeader.parse(['gzip, chunked']); + + expect( + header.encodings.map((final e) => e.name), + equals(['gzip', 'chunked']), + ); + }); + }); + + group('Given "chunked" is not the last coding,', () { + test('when parsed, ' + 'then it throws (RFC 9112 6.1) instead of silently reordering.', () { + expect( + () => TransferEncodingHeader.parse(['chunked, gzip']), + throwsFormatException, + ); + }); + }); + + group('Given a coding in mixed case,', () { + test('when parsed, ' + 'then it is matched case-insensitively.', () { + final header = TransferEncodingHeader.parse(['GZIP, Chunked']); + + expect( + header.encodings.map((final e) => e.name), + equals(['gzip', 'chunked']), + ); + }); + }); + + group('Given duplicate codings,', () { + test('when parsed, ' + 'then duplicates are removed.', () { + final header = TransferEncodingHeader.parse(['gzip, chunked, chunked']); + + expect( + header.encodings.map((final e) => e.name), + equals(['gzip', 'chunked']), + ); + }); + }); + + group('Given a mixed-case duplicate chunked,', () { + test('when parsed, ' + 'then it dedupes by canonical name and does not falsely reject.', () { + final header = TransferEncodingHeader.parse(['gzip, chunked, CHUNKED']); + + expect( + header.encodings.map((final e) => e.name), + equals(['gzip', 'chunked']), + ); + }); + }); + + group('Given a value that contains "chunked",', () { + test('when parsed, ' + 'then chunked is among the encodings.', () { + final header = TransferEncodingHeader.parse(['gzip, chunked']); + + expect( + header.encodings.any( + (final e) => e.name == TransferEncoding.chunked.name, + ), + isTrue, + ); + }); + }); + + group('Given an invalid coding,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect( + () => TransferEncodingHeader.parse(['custom-encoding']), + throwsFormatException, + ); + }); + }); + + group('Given an empty value,', () { + test('when parsed, ' + 'then it throws a FormatException.', () { + expect(() => TransferEncodingHeader.parse(['']), throwsFormatException); + }); + }); + }); + + group('TransferEncodingHeader encoding', () { + group('Given a valid header with chunked last,', () { + test('when encoded, ' + 'then the codings render in order.', () { + final header = TransferEncodingHeader.parse(['gzip, chunked']); + + expect( + TransferEncodingHeader.codec.encode(header), + equals(['gzip, chunked']), + ); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/util/qvalue_test.dart b/packages/relic_core/test/headers/typed/util/qvalue_test.dart new file mode 100644 index 00000000..aaf3ed31 --- /dev/null +++ b/packages/relic_core/test/headers/typed/util/qvalue_test.dart @@ -0,0 +1,54 @@ +import 'package:relic_core/src/headers/typed/headers/util/qvalue.dart'; +import 'package:test/test.dart'; + +void main() { + group('formatQValue', () { + group('Given a value with up to 3 significant digits,', () { + test('when formatted, ' + 'then trailing zeros are stripped.', () { + expect(formatQValue(0.5), equals('0.5')); + expect(formatQValue(0.8), equals('0.8')); + expect(formatQValue(0.333), equals('0.333')); + }); + }); + + group('Given a value just under 1.0,', () { + test('when formatted, ' + 'then it truncates to 0.999 rather than rounding up to 1.', () { + expect(formatQValue(0.9999), equals('0.999')); + }); + }); + + group('Given a value with more than 3 fractional digits,', () { + test('when formatted, ' + 'then it truncates toward zero to 3 digits.', () { + expect(formatQValue(0.3335), equals('0.333')); + }); + }); + + group('Given a value extremely close to but below 1.0,', () { + test('when formatted, ' + 'then it truncates to 0.999 rather than rounding up to 1.', () { + expect(formatQValue(0.99996), equals('0.999')); + expect(formatQValue(0.999999), equals('0.999')); + }); + }); + + group('Given a small value with a single significant millis digit,', () { + test('when formatted, ' + 'then leading zeros are kept.', () { + expect(formatQValue(0.005), equals('0.005')); + expect(formatQValue(0.05), equals('0.05')); + expect(formatQValue(0.001), equals('0.001')); + }); + }); + + group('Given the bounds,', () { + test('when formatted, ' + 'then 0 and 1 render without a fraction.', () { + expect(formatQValue(0), equals('0')); + expect(formatQValue(1), equals('1')); + }); + }); + }); +} diff --git a/packages/relic_core/test/headers/typed/util/report_to_test.dart b/packages/relic_core/test/headers/typed/util/report_to_test.dart new file mode 100644 index 00000000..04f79016 --- /dev/null +++ b/packages/relic_core/test/headers/typed/util/report_to_test.dart @@ -0,0 +1,24 @@ +import 'package:relic_core/src/headers/typed/headers/util/report_to.dart'; +import 'package:test/test.dart'; + +void main() { + group('encodeReportToParam', () { + group('Given a normal value,', () { + test('when encoded, ' + 'then it is wrapped and escaped as report-to="...".', () { + expect(encodeReportToParam('endpoint'), equals('report-to="endpoint"')); + expect(encodeReportToParam(r'a"b'), equals(r'report-to="a\"b"')); + }); + }); + + group('Given a value with a control character,', () { + test('when encoded, ' + 'then it throws to prevent header injection.', () { + expect( + () => encodeReportToParam('a\r\nInjected: evil'), + throwsFormatException, + ); + }); + }); + }); +} diff --git a/packages/relic_core/test/middleware_extra/create_middleware_test.dart b/packages/relic_core/test/middleware_extra/create_middleware_test.dart index 83b1981f..cf79057e 100644 --- a/packages/relic_core/test/middleware_extra/create_middleware_test.dart +++ b/packages/relic_core/test/middleware_extra/create_middleware_test.dart @@ -15,14 +15,13 @@ void main() { .addHandler( createSyncHandler( headers: Headers.build( - (final mh) => - mh.from = FromHeader.emails(['next@serverpod.dev']), + (final mh) => mh.from = FromHeader('next@serverpod.dev'), ), ), ); final response = await makeSimpleRequest(handler); - expect(response.headers.from?.emails, contains('next@serverpod.dev')); + expect(response.headers.from?.mailbox, contains('next@serverpod.dev')); }, ); @@ -64,7 +63,7 @@ void main() { final response = await makeSimpleRequest(handler); expect( - response.headers.from?.emails, + response.headers.from?.mailbox, contains('middleware@serverpod.dev'), ); }); @@ -80,7 +79,7 @@ void main() { final response = await makeSimpleRequest(handler); expect( - response.headers.from?.emails, + response.headers.from?.mailbox, contains('middleware@serverpod.dev'), ); }); @@ -100,7 +99,7 @@ void main() { final response = await makeSimpleRequest(handler); expect( - response.headers.from?.emails, + response.headers.from?.mailbox, contains('middleware@serverpod.dev'), ); }, @@ -119,7 +118,7 @@ void main() { final response = await makeSimpleRequest(handler); expect( - response.headers.from?.emails, + response.headers.from?.mailbox, contains('middleware@serverpod.dev'), ); }, @@ -136,7 +135,7 @@ void main() { createMiddleware( onResponse: (final response) { expect( - response.headers.from?.emails, + response.headers.from?.mailbox, contains('handler@serverpod.dev'), ); return _middlewareResponse; @@ -146,15 +145,14 @@ void main() { .addHandler( createSyncHandler( headers: Headers.build( - (final mh) => - mh.from = FromHeader.emails(['handler@serverpod.dev']), + (final mh) => mh.from = FromHeader('handler@serverpod.dev'), ), ), ); final response = await makeSimpleRequest(handler); expect( - response.headers.from?.emails, + response.headers.from?.mailbox, contains('middleware@serverpod.dev'), ); }, @@ -168,7 +166,7 @@ void main() { createMiddleware( onResponse: (final response) { expect( - response.headers.from?.emails, + response.headers.from?.mailbox, contains('handler@serverpod.dev'), ); return Future.value(_middlewareResponse); @@ -179,8 +177,7 @@ void main() { return Future( () => createSyncHandler( headers: Headers.build( - (final mh) => - mh.from = FromHeader.emails(['handler@serverpod.dev']), + (final mh) => mh.from = FromHeader('handler@serverpod.dev'), ), )(req), ); @@ -188,7 +185,7 @@ void main() { final response = await makeSimpleRequest(handler); expect( - response.headers.from?.emails, + response.headers.from?.mailbox, contains('middleware@serverpod.dev'), ); }, @@ -279,7 +276,7 @@ void main() { final response = await makeSimpleRequest(handler); expect( - response.headers.from?.emails, + response.headers.from?.mailbox, contains('middleware@serverpod.dev'), ); }, @@ -331,6 +328,6 @@ Response _failHandler(final Request request) => fail('should never get here'); final Response _middlewareResponse = Response.ok( body: Body.fromString('middleware content'), headers: Headers.build( - (final mh) => mh.from = FromHeader.emails(['middleware@serverpod.dev']), + (final mh) => mh.from = FromHeader('middleware@serverpod.dev'), ), ); diff --git a/packages/relic_io/lib/src/adapter/http_response_extension.dart b/packages/relic_io/lib/src/adapter/http_response_extension.dart index d00c60b2..43d50c15 100644 --- a/packages/relic_io/lib/src/adapter/http_response_extension.dart +++ b/packages/relic_io/lib/src/adapter/http_response_extension.dart @@ -30,8 +30,10 @@ extension HttpResponseExtension on io.HttpResponse { return; } - // Otherwise, we need to consider chunked encoding - final encodings = headers.transferEncoding?.encodings ?? []; + // Otherwise, we need to consider chunked encoding. Copy into a growable + // list: TransferEncodingHeader.encodings is unmodifiable, so adding the + // chunked coding below would otherwise throw. + final encodings = [...?headers.transferEncoding?.encodings]; final isChunked = headers.transferEncoding?.isChunked ?? false; final isIdentity = headers.transferEncoding?.isIdentity ?? false; final shouldEnableChunkedEncoding = _shouldEnableChunkedEncoding(body); @@ -42,11 +44,14 @@ extension HttpResponseExtension on io.HttpResponse { encodings.add(TransferEncoding.chunked); } - // Set the transfer encoding header. - responseHeaders.set( - Headers.transferEncodingHeader, - encodings.map((final e) => e.name).toList(), - ); + // Set the transfer encoding header (only when there is a coding to emit; + // an empty list would otherwise set an empty Transfer-Encoding). + if (encodings.isNotEmpty) { + responseHeaders.set( + Headers.transferEncodingHeader, + encodings.map((final e) => e.name).toList(), + ); + } } /// Check if chunked encoding should be applied. diff --git a/packages/relic_io/lib/src/io/static/static_handler.dart b/packages/relic_io/lib/src/io/static/static_handler.dart index 2573bd64..48a18c28 100644 --- a/packages/relic_io/lib/src/io/static/static_handler.dart +++ b/packages/relic_io/lib/src/io/static/static_handler.dart @@ -405,9 +405,10 @@ bool _isRangeRequestValid(final Request req, final FileInfo fileInfo) { final ifRange = req.headers.ifRange; if (ifRange == null) return true; - // Check ETag match + // Check ETag match. A weak validator cannot satisfy a range request + // (RFC 9110 13.1.5): treat it as a no-match so the full file is served. final etag = ifRange.etag; - if (etag != null) return etag.value == fileInfo.etag; + if (etag != null) return !etag.isWeak && etag.value == fileInfo.etag; // Check Last-Modified match final lastModified = ifRange.lastModified; diff --git a/packages/relic_io/test/static/if_range_test.dart b/packages/relic_io/test/static/if_range_test.dart index 38e04ea4..7fd77463 100644 --- a/packages/relic_io/test/static/if_range_test.dart +++ b/packages/relic_io/test/static/if_range_test.dart @@ -66,6 +66,33 @@ void main() { expect(response.readAsString(), completion(fileContent)); }); + test( + 'Given an If-Range header with a weak ETag whose value matches the current one, ' + 'when a range request is made, ' + 'then a 200 OK with full content is returned (a weak validator cannot satisfy a range, RFC 9110 13.1.5)', + () async { + final initialResponse = await makeRequest(handler, '/test_file.txt'); + final etag = initialResponse.headers.etag!.value; + final headers = Headers.build( + (final mh) => mh + ..range = RangeHeader(ranges: [Range(start: 0, end: 4)]) + ..ifRange = IfRangeHeader( + etag: ETagHeader(value: etag, isWeak: true), + ), + ); + + final response = await makeRequest( + handler, + '/test_file.txt', + headers: headers, + ); + + expect(response.statusCode, HttpStatus.ok); + expect(response.body.contentLength, fileContent.length); + expect(response.readAsString(), completion(fileContent)); + }, + ); + test( 'Given an If-Range header with a matching Last-Modified date, ' 'when a range request is made, '