kekse 0.2.0

A strict, dependency-light cookie codec: a Set-Cookie builder and a Cookie-header parser built on the RFC 6265 grammar.
Documentation

kekse

A strict, dependency-light cookie codec.

Highlights

  • Both directions. Build Set-Cookie via SetCookie. Read and write Cookie via a CookieJar of typed Cookies. Convert either into an http::HeaderValue.
  • Built to RFC 6265. Plus RFC 7230 tokens, RFC 7231 dates, RFC 6265bis SameSite and name prefixes, and CHIPS Partitioned.
  • 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 a 500, 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 store feature adds a typed client-side CookieStore whose one added dependency is the url crate.
  • Dates handled. Max-Age seconds (a u64) or an Expires timestamp, via the time crate.
  • Light and safe. Three dependencies — percent-encoding, http, time. No default features, no unsafe. 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 kekse::{Path, SetCookie, SameSite, ValueEncoding};

let header = SetCookie::new("SID", "deadbeef")
    .with_encoding(ValueEncoding::Percent)
    .http_only()
    .same_site(SameSite::Strict)
    .secure()
    .path(Path::new("/")?)
    .max_age(3600)
    .to_set_cookie();
assert_eq!(header, "SID=deadbeef; HttpOnly; SameSite=Strict; Secure; Path=/; Max-Age=3600");

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 = kekse::parse_pairs_strict("SID=deadbeef; theme=dark")
    .filter_map(Result::ok)
    .find(|(name, _)| *name == "SID")
    .map(|(_, value)| value.into_owned());
assert_eq!(value.as_deref(), Some("deadbeef"));

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 fn whoami(cookies: CookieJarBuf) -> Result<String, BadCookieHeader> {
    let jar = cookies.try_jar_strict()?; // any malformed pair -> 400
    Ok(jar.get("SID").map(|c| c.value().to_owned()).unwrap_or_default())
}

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 fn login() -> (SetCookie<'static>, &'static str) {
    (SetCookie::new("SID", "deadbeef").http_only().secure(), "logged in")
}

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 kekse::{CookieStore, OffsetDateTime};

let now = OffsetDateTime::from_unix_timestamp(1_752_000_000)?;
let origin = url::Url::parse("https://shop.example.test/login")?;

let mut store = CookieStore::new();
store.insert_all(
    &origin,
    ["SID=deadbeef; Secure; HttpOnly; Path=/", "theme=dark; Max-Age=31536000"],
    now,
);

// The next request to that origin carries both (§5.4.2 order)…
let request = url::Url::parse("https://shop.example.test/cart")?;
assert_eq!(store.cookie_header(&request, now).unwrap(), "SID=deadbeef; theme=dark");
// …a plain-HTTP request only the non-Secure one.
let insecure = url::Url::parse("http://shop.example.test/cart")?;
assert_eq!(store.cookie_header(&insecure, now).unwrap(), "theme=dark");

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 kekse::CookieJar;

let strict = CookieJar::parse_strict("SID=deadbeef; theme=dark");
assert!(strict.is_clean()); // 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("SID")
    .find(|c| !c.value().is_empty())
    .map(|c| c.value().to_owned());
assert_eq!(sid.as_deref(), Some("deadbeef"));

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).
cargo run -p kekse --example build_set_cookie
cargo run -p kekse --example axum_extractor --features axum

License

Licensed under the MIT License.