diff --git a/CHANGELOG.md b/CHANGELOG.md index 18724966..0c8adcc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +- make `httpOnly`, `secure`, and `partitioned` two-state flags with `false` defaults +- treat omitted flags and explicit `false` as the same value semantics +- remove `CookieNullableField.httpOnly`, `CookieNullableField.secure`, and `CookieNullableField.partitioned` +- migrate `copyWith(clear: {...})` call sites by dropping those removed fields and using omitted flags or explicit `false` instead + ## 0.1.0 - add `Cookie.validate` for explicit pre-serialization checks diff --git a/README.md b/README.md index 0dee6267..98baaa93 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,11 @@ if (errors.isNotEmpty) { - `SameSite=None` requires `Secure=true`. - `Partitioned=true` requires `Secure=true`. +## Flag Semantics + +- `HttpOnly`, `Secure`, and `Partitioned` are two-state flags. +- Omitting a flag is equivalent to setting it to `false`. + # API Reference See the [API documentation](https://pub.dev/documentation/ocookie) for detailed information about all available APIs. diff --git a/lib/src/cookie.dart b/lib/src/cookie.dart index ada6e9e1..e0086287 100644 --- a/lib/src/cookie.dart +++ b/lib/src/cookie.dart @@ -32,13 +32,10 @@ enum CookieSameSite { enum CookieNullableField { expires, domain, - httpOnly, maxAge, path, priority, sameSite, - secure, - partitioned, } class Cookie { @@ -49,26 +46,26 @@ class Cookie { this.value, { this.expires, this.domain, - this.httpOnly, + this.httpOnly = false, this.maxAge, this.path, this.priority, this.sameSite, - this.secure, - this.partitioned, + this.secure = false, + this.partitioned = false, }); final String name; final String value; final DateTime? expires; final String? domain; - final bool? httpOnly; + final bool httpOnly; final Duration? maxAge; final String? path; final CookiePriority? priority; final CookieSameSite? sameSite; - final bool? secure; - final bool? partitioned; + final bool secure; + final bool partitioned; Cookie copyWith({ String? name, @@ -100,17 +97,10 @@ class Cookie { assertNoConflict(CookieNullableField.expires, expires, 'expires'); assertNoConflict(CookieNullableField.domain, domain, 'domain'); - assertNoConflict(CookieNullableField.httpOnly, httpOnly, 'httpOnly'); assertNoConflict(CookieNullableField.maxAge, maxAge, 'maxAge'); assertNoConflict(CookieNullableField.path, path, 'path'); assertNoConflict(CookieNullableField.priority, priority, 'priority'); assertNoConflict(CookieNullableField.sameSite, sameSite, 'sameSite'); - assertNoConflict(CookieNullableField.secure, secure, 'secure'); - assertNoConflict( - CookieNullableField.partitioned, - partitioned, - 'partitioned', - ); return Cookie( name ?? this.name, @@ -121,9 +111,7 @@ class Cookie { domain: clear.contains(CookieNullableField.domain) ? null : domain ?? this.domain, - httpOnly: clear.contains(CookieNullableField.httpOnly) - ? null - : httpOnly ?? this.httpOnly, + httpOnly: httpOnly ?? this.httpOnly, maxAge: clear.contains(CookieNullableField.maxAge) ? null : maxAge ?? this.maxAge, @@ -134,12 +122,8 @@ class Cookie { sameSite: clear.contains(CookieNullableField.sameSite) ? null : sameSite ?? this.sameSite, - secure: clear.contains(CookieNullableField.secure) - ? null - : secure ?? this.secure, - partitioned: clear.contains(CookieNullableField.partitioned) - ? null - : partitioned ?? this.partitioned, + secure: secure ?? this.secure, + partitioned: partitioned ?? this.partitioned, ); } @@ -171,12 +155,12 @@ class Cookie { if (domain?.isNotEmpty == true && !cookieAllowPattern.hasMatch(domain!)) { errors.add('domain is invalid'); } - if (sameSite == CookieSameSite.none && secure != true) { + if (sameSite == CookieSameSite.none && !secure) { errors.add( 'SameSite attribute is set to none, but the secure flag is not set to true.', ); } - if (partitioned == true && secure != true) { + if (partitioned && !secure) { errors.add( 'Partitioned attribute is set, but the secure flag is not set to true.', ); @@ -202,11 +186,11 @@ class Cookie { if (domain?.isNotEmpty == true && !cookieAllowPattern.hasMatch(domain!)) { throw ArgumentError.value(domain, 'domain', 'domain is invalid'); } - if (sameSite == CookieSameSite.none && secure != true) { + if (sameSite == CookieSameSite.none && !secure) { throw StateError( 'SameSite attribute is set to none, but the secure flag is not set to true.'); } - if (partitioned == true && secure != true) { + if (partitioned && !secure) { throw StateError( 'Partitioned attribute is set, but the secure flag is not set to true.'); } @@ -217,8 +201,8 @@ class Cookie { if (domain?.isNotEmpty == true) 'Domain=$domain', if (path?.isNotEmpty == true) 'Path=$path', if (expires != null) 'Expires=${formatHttpDate(expires!)}', - if (httpOnly == true) 'HttpOnly', - if (secure == true) 'Secure', + if (httpOnly) 'HttpOnly', + if (secure) 'Secure', if (priority != null) 'Priority=${switch (priority!) { CookiePriority.low => 'Low', @@ -231,7 +215,7 @@ class Cookie { CookieSameSite.lax => 'Lax', CookieSameSite.none => 'None', }}', - if (partitioned == true) 'Partitioned', + if (partitioned) 'Partitioned', ]; return parts.join('; '); @@ -305,6 +289,14 @@ class Cookie { return value; } + bool parseFlagValue(String value) { + final normalized = value.trim().toLowerCase(); + return switch (normalized) { + 'false' || '0' || '?0' => false, + _ => true, + }; + } + final firstPair = parts.first; if (!firstPair.contains('=')) { throw ArgumentError.value( @@ -330,9 +322,9 @@ class Cookie { cookie = switch (name.trim().toLowerCase()) { 'expires' => cookie.copyWith(expires: parseExpiresValue(value)), 'max-age' => cookie.copyWith(maxAge: parseMaxAgeValue(value)), - 'secure' => cookie.copyWith(secure: true), - 'httponly' => cookie.copyWith(httpOnly: true), - 'partitioned' => cookie.copyWith(partitioned: true), + 'secure' => cookie.copyWith(secure: parseFlagValue(value)), + 'httponly' => cookie.copyWith(httpOnly: parseFlagValue(value)), + 'partitioned' => cookie.copyWith(partitioned: parseFlagValue(value)), 'path' => cookie.copyWith(path: value), 'domain' => cookie.copyWith(domain: value), 'samesite' => cookie.copyWith( diff --git a/test/copy_with_test.dart b/test/copy_with_test.dart index 88af7a2b..763f2bf9 100644 --- a/test/copy_with_test.dart +++ b/test/copy_with_test.dart @@ -55,25 +55,22 @@ void main() { clear: { CookieNullableField.expires, CookieNullableField.domain, - CookieNullableField.httpOnly, CookieNullableField.maxAge, CookieNullableField.path, CookieNullableField.priority, CookieNullableField.sameSite, - CookieNullableField.secure, - CookieNullableField.partitioned, }, ); expect(result.expires, isNull); expect(result.domain, isNull); - expect(result.httpOnly, isNull); + expect(result.httpOnly, isTrue); expect(result.maxAge, isNull); expect(result.path, isNull); expect(result.priority, isNull); expect(result.sameSite, isNull); - expect(result.secure, isNull); - expect(result.partitioned, isNull); + expect(result.secure, isTrue); + expect(result.partitioned, isTrue); }); test('should throw when a field is set and cleared together', () { @@ -107,5 +104,19 @@ void main() { expect(a, equals(b)); expect(a.hashCode, b.hashCode); }); + + test('should treat omitted flags and false flags as equal', () { + final a = Cookie('sid', 'abc'); + final b = Cookie( + 'sid', + 'abc', + httpOnly: false, + secure: false, + partitioned: false, + ); + + expect(a, equals(b)); + expect(a.hashCode, b.hashCode); + }); }); } diff --git a/test/from_string_test.dart b/test/from_string_test.dart index cbbe8139..be12dc58 100644 --- a/test/from_string_test.dart +++ b/test/from_string_test.dart @@ -44,6 +44,66 @@ void main() { final cookie = Cookie.fromString('a="hello%20world"'); expect(cookie.value, 'hello world'); + expect(cookie.httpOnly, isFalse); + expect(cookie.secure, isFalse); + expect(cookie.partitioned, isFalse); + }); + + test('should preserve quoted values containing equals signs', () { + final cookie = Cookie.fromString('session="a=b=c%202"'); + + expect(cookie.value, 'a=b=c 2'); + }); + + test('should ignore unknown attributes from real-world headers', () { + final cookie = Cookie.fromString( + 'sid=abc123; Path=/; HttpOnly; Secure; SameSite=None; Foo=bar; Version=1', + ); + + expect(cookie.name, 'sid'); + expect(cookie.value, 'abc123'); + expect(cookie.path, '/'); + expect(cookie.httpOnly, isTrue); + expect(cookie.secure, isTrue); + expect(cookie.sameSite, CookieSameSite.none); + expect(cookie.priority, isNull); + }); + + test('should keep the last repeated recognized attribute', () { + final cookie = Cookie.fromString( + 'sid=abc; Path=/one; Path=/two; SameSite=Lax; SameSite=None; Secure', + ); + + expect(cookie.path, '/two'); + expect(cookie.sameSite, CookieSameSite.none); + expect(cookie.secure, isTrue); + }); + + test('should treat non-standard flag values predictably', () { + final enabledCookie = Cookie.fromString( + 'sid=abc; Secure=1; HttpOnly=true; Partitioned=?1', + ); + final disabledCookie = Cookie.fromString( + 'sid=abc; Secure=false; HttpOnly=false; Partitioned=false', + ); + + expect(enabledCookie.secure, isTrue); + expect(enabledCookie.httpOnly, isTrue); + expect(enabledCookie.partitioned, isTrue); + expect(disabledCookie.secure, isFalse); + expect(disabledCookie.httpOnly, isFalse); + expect(disabledCookie.partitioned, isFalse); + }); + + test('should ignore invalid recognized attribute values without failing', + () { + final cookie = Cookie.fromString( + 'sid=abc; SameSite=invalid; Priority=urgent; Secure', + ); + + expect(cookie.sameSite, isNull); + expect(cookie.priority, isNull); + expect(cookie.secure, isTrue); }); test('should throw for invalid first pair without equals', () { @@ -90,5 +150,18 @@ void main() { 'c=d', ]); }); + + test('should split combined headers with quoted commas and expires dates', + () { + final values = Cookie.splitSetCookie( + 'a="b,c"; Path=/, sid=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT; HttpOnly, theme=light', + ); + + expect(values, [ + 'a="b,c"; Path=/', + 'sid=1; Expires=Wed, 21 Oct 2015 07:28:00 GMT; HttpOnly', + 'theme=light', + ]); + }); }); }