Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +3 to +4
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Document the clear API break explicitly.

Lines 3-4 describe the semantic shift, but the compile-time break most consumers will hit is that CookieNullableField.httpOnly, CookieNullableField.secure, and CookieNullableField.partitioned are gone. Please call that out here so the migration path is discoverable from the release notes.

✏️ Suggested changelog wording
-- make `httpOnly`, `secure`, and `partitioned` two-state flags with `false` defaults
-- treat omitted flags and explicit `false` as the same value semantics
+- BREAKING: make `httpOnly`, `secure`, and `partitioned` two-state flags with `false` defaults
+- BREAKING: treat omitted flags and explicit `false` as the same value semantics
+- BREAKING: remove `CookieNullableField.httpOnly`, `CookieNullableField.secure`, and
+  `CookieNullableField.partitioned`; `copyWith(clear: ...)` can no longer clear those flags
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 3 - 4, Update the changelog entry to explicitly
document the breaking API change: state that the properties
CookieNullableField.httpOnly, CookieNullableField.secure, and
CookieNullableField.partitioned have been removed (compile-time break) and
describe the migration path (e.g., replace usages with the new two-state flags
on Cookie or set explicit false/omitted semantics accordingly), so consumers
know how to update their code; ensure the wording appears near the existing
lines about flag semantics to make the relationship clear.

- 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
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
62 changes: 27 additions & 35 deletions lib/src/cookie.dart
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,10 @@ enum CookieSameSite {
enum CookieNullableField {
expires,
domain,
httpOnly,
maxAge,
path,
priority,
sameSite,
secure,
partitioned,
}

class Cookie {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
);
}

Expand Down Expand Up @@ -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.',
);
Expand All @@ -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.');
}
Expand All @@ -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',
Expand All @@ -231,7 +215,7 @@ class Cookie {
CookieSameSite.lax => 'Lax',
CookieSameSite.none => 'None',
}}',
if (partitioned == true) 'Partitioned',
if (partitioned) 'Partitioned',
];

return parts.join('; ');
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down
23 changes: 17 additions & 6 deletions test/copy_with_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down Expand Up @@ -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);
});
});
}
73 changes: 73 additions & 0 deletions test/from_string_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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', () {
Expand Down Expand Up @@ -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',
]);
});
});
}
Loading