kekse
A strict, dependency-light cookie codec.
Highlights
- Both directions. Build
Set-CookieviaSetCookie. Read and writeCookievia aCookieJarof typedCookies. Convert either into anhttp::HeaderValue. - Built to RFC 6265. Plus RFC 7230 tokens, RFC 7231 dates, RFC 6265bis
SameSiteand name prefixes, and CHIPSPartitioned. - One interface, two gradings. Both refuse injection bytes (
;, CR, LF, NUL, controls, non-ASCII); strict also demands cookie-octets only, and accepts a subset of what lenient accepts. - Fail-soft, never silent. A junk pair is skipped, not fatal — it can't evict a valid cookie — and every reader returns what it refused, plus an opt-in axum
400(and the response side fails loudly with a500, never dropping a cookie). - Strongly typed.
Cookie,SetCookie,CookieJar, and typed attributes. Never string maps. - A codec first — the store is opt-in. The default build has no persistence, eviction, or send-matching (and never signing or encryption); the
storefeature adds a typed client-sideCookieStorewhose one added dependency is theurlcrate. - Dates handled.
Max-Ageseconds (au64) or anExpirestimestamp, via thetimecrate. - Light and safe. Three dependencies —
percent-encoding,http,time. No default features, nounsafe. Rust 1.88+.
Building a value
RFC 6265 lets a cookie value carry only "cookie-octets".
Anything else — a space, a ;, a ", a control byte, any non-ASCII — has to be escaped to travel on the wire.
The with_encoding builder chooses how:
ValueEncoding |
Behaviour |
|---|---|
Auto |
Bare when the value is already cookie-octets, quoted to carry whitespace (a b → "a b"), percent-encoded otherwise. "Quotes where necessary." |
Percent (default) |
Always percent-encode, never quote. The most compatible form, understood by every parser — the sane default. |
Quoted |
Always wrap in quotes, percent-encoding inside any byte the bare quoted form cannot carry. |
Raw |
Emit verbatim. The escape hatch for uncommon but deliberate shapes; the caller owns wire-correctness. |
Every managed encoding is lossless and unambiguous.
% always self-encodes to %25, and a quoted value never carries a raw "/\ (they become %22/%5C), so the wrapping quotes can never be faked.
use ;
let header = new
.with_encoding
.http_only
.same_site
.secure
.path
.max_age
.to_set_cookie;
assert_eq!;
The verbs are optional sugar over CookieAttributes, a plain Default struct; the build_set_cookie example builds the same cookie three ways.
Attributes are emitted in a fixed order: HttpOnly, SameSite, Secure, Partitioned, Path, Domain, Expires, Max-Age, each only when set.
The RFC 6265bis §4.1.3 name prefixes (__Host-/__Secure-) and CHIPS' Partitioned/Secure pairing are modeled as witnessed constraints, never enforced: a parse keeps a violating cookie exactly as written and reports a ConstraintViolation issue in both gradings, and set_cookie.constraint_violations() runs the same checks on a cookie you build.
The checks match prefixes case-insensitively, the way user agents enforce the rules — the §4.1.3 server contract itself is the canonical, case-sensitive spelling.
To hand the cookie straight to http, use HeaderValue::try_from(set_cookie) (or &set_cookie) instead of .to_set_cookie(): the managed encodings are always valid header bytes, so it fails only for a Raw value the caller deliberately built with non-header bytes.
Parsing a header
One interface, two gradings — and every reader returns what it refused.
parse_pairs is the lenient stream — the inverse of every ValueEncoding.
It strips one wrapping quote pair, accepts raw whitespace, and percent-decodes.
parse_pairs_strict is the strict grading.
It accepts only cookie-octets — whitespace and every other non-octet are refused, and witnessed — which is what a session-cookie read should use.
Both refuse the injection-dangerous bytes (;, CR, LF, NUL, other controls, raw non-ASCII) under either grading, and strict accepts a subset of what lenient accepts, never something else.
The streams yield each well-formed pair as Ok and each refused pair as an Err(PairIssue) in place, so fail-soft is .filter_map(Result::ok) and .collect::<Result<Vec<_>, _>>() is fail-hard for free.
CookieJar::parse / parse_strict collect the same streams into a Reported: the jar, plus every refused pair.
SetCookie::parse / parse_strict return the salvaged cookie plus each recovered deviation as a SetCookieIssue — an ignored unknown attribute, a duplicate, a malformed known value; the one fatal case is a missing usable name=value pair.
The severity of an issue is always the caller's choice, never the parser's: gate on Reported::is_clean() and nothing is ever dropped silently.
let value = parse_pairs_strict
.filter_map
.find
.map;
assert_eq!;
With the axum feature, that gate is one line in a handler — anything wrong with the header becomes a 400 Bad Request that reports a count and never echoes header bytes:
async
The response side is symmetric: return the SetCookie from the handler and the header is appended — cookies accumulate, never overwrite — with the one failable case (a Raw value carrying a header-illegal byte) surfacing as a typed 500 instead of a silently dropped cookie:
async
Storing cookies (optional)
The codec is stateless by design; the opt-in store feature adds the state.
CookieStore does RFC 6265 §5.3 storage and §5.4 send-matching over the same parsed cookies, with the RFC 6265bis storage gates user agents apply: a Secure cookie only from a secure origin, the __Host-/__Secure- prefix requirements, and CHIPS' Partitioned/Secure pairing (a case-variant prefix whose requirements are met stores verbatim, as engines do).
Time is data — every time-sensitive call takes now — and every outcome is a typed Insertion (stored / replaced / deleted / rejected with a reason), never a silent drop.
The negative-Max-Age delete idiom is honored from the parse's witness channel, and with psl on, ingest follows §5.3 step 5 exactly: a public-suffix Domain naming a foreign host rejects the cookie, one naming the origin itself degrades to host-only.
Origins and requests are url::Urls — the URL your HTTP stack already holds — so hosts arrive lowercased and IDNA-encoded, and the secure bit is the URL's own: a TLS scheme (https/wss), or a loopback destination (localhost, *.localhost, loopback IPs), the trustworthy-origin convention user agents apply.
The url crate is the feature's one added dependency; the matching itself is rfc_6265's table-free domain/path primitives.
use ;
let now = from_unix_timestamp?;
let origin = parse?;
let mut store = new;
store.insert_all;
// The next request to that origin carries both (§5.4.2 order)…
let request = parse?;
assert_eq!;
// …a plain-HTTP request only the non-Secure one.
let insecure = parse?;
assert_eq!;
The store is a plain value — wrap it in the lock of your choice (RwLock<CookieStore>) to share it.
The companion serde feature adds PersistedStore — export/import of the stored representation (never the codec's wire types) through any serde format.
Three types, two headers
| Type | What it is |
|---|---|
Cookie |
one name=value from a request Cookie: header — no attributes; the shared core |
SetCookie |
a response Set-Cookie: — a Cookie plus typed CookieAttributes (HttpOnly, Secure, Partitioned, SameSite, Path, Domain, Expires, Max-Age) |
CookieJar |
an ordered, writable view over a Cookie: header — parsed and rebuildable, not a store |
CookieStore (feature store) |
the stateful client store above both directions — §5.3 ingest of Set-Cookie lines, §5.4 send-matching rendered back out through a CookieJar |
A request Cookie completes into a SetCookie with into_set_cookie() (default attributes) or with_attributes(..), and drops back with into_cookie(). The attribute verbs also build a standalone CookieAttributes, so one hardened policy can be reused across cookies.
use CookieJar;
let strict = parse_strict;
assert!; // every refusal would be witnessed here
// First non-empty match, so a stale `SID=` can't shadow a later real one.
let sid = strict
.value
.get_all
.find
.map;
assert_eq!;
The jar is writable too: add / replace / remove, then to_header_value(encoding) re-encodes the whole header canonically from its decoded form.
Examples
Runnable programs live in examples/.
Each prints its output and asserts the invariant it documents, so it doubles as a smoke test.
| Example | Shows |
|---|---|
build_set_cookie |
Three ways to build a SetCookie — inline builder, a reusable CookieAttributes policy, a struct literal — plus the HeaderValue conversion and a parse round-trip. |
parse_request |
Reading and rewriting a Cookie: request header through a CookieJar. |
encodings |
How each ValueEncoding escapes one tricky value for the wire. |
strict_vs_lenient |
The lenient and strict readers side by side on a quoted value. |
fail_soft |
Fail-soft parsing, the issue report for the same header, and the no-panic behaviour on hostile input. |
axum_extractor |
The axum integration, both directions: a handler returning a SetCookie, the extractor reading it back, and the fail-hard try_jar_strict → 400 route (needs --features axum). |
License
Licensed under the MIT License.