RFC 9651 Structured Fields

Small set of cross-primitive helpers for parsing RFC 8941 Structured Fields header values without each parser open-coding its own quote-aware top-level splitter. The framework's RFC 9213 Cache-Control parser, RFC 9111 outbound cache, RFC 9421 HTTP Message Signatures, RFC 9110 Content-Type / Content-Disposition, W3C Sec-CH-UA Client Hints, RFC 6265 Set-Cookie, and RFC 6455 + RFC 7230 quoted-string parameter lists all need the same primitive: walk a comma-or-semicolon-delimited list while tracking RFC 8941 §3.3.3 quoted-string state with backslash- escape so a , or ; inside "..." doesn't fake-split the list.

splitTopLevel(s, sep) returns the array of top-level pieces. sep must be , or ;. Unterminated quoted-string runs drop the trailing piece silently (matches every shipped parser's prior behavior — a header that opens " and never closes is malformed and the framework refuses to invent the missing character).

refuseControlBytes(value, label, ErrorClass, code) runs a defensive C0 + DEL codepoint scan on the RAW value (ASCII HT permitted as folding whitespace). The throw discipline matches b.mail.requireTls.parseTlsRequiredHeader — gate the value BEFORE any .trim() strips leading/trailing C0/DEL bytes.

unquoteSfString(s) strips RFC 8941 §3.3.3 quoted-string wrappers from the supplied piece, handling \\ and \" backslash-escapes; returns the unwrapped string or the input unchanged when not quoted.

b.structuredFields.splitTopLevel(s, sep) #

stable0.9.0

Split s on top-level occurrences of sep (one of , or ;), respecting RFC 8941 §3.3.3 quoted-string boundaries with backslash-escape. Returns the array of trimmed-by-caller pieces.

Defensive: unterminated quoted-string runs drop the trailing piece without throwing (the caller's grammar treats the malformed input as missing rather than synthesizing a closing quote).

b.structuredFields.splitTopLevel('private="A, B", max-age=60', ",");
// → ['private="A, B"', ' max-age=60']

b.structuredFields.splitTopLevel('alg="x;y";nonce=42', ";");
// → ['alg="x;y"', 'nonce=42']

b.structuredFields.splitUnquoted(s, sep) #

stable0.15.13

Split s on every sep that falls OUTSIDE a "..." quoted run — the iCalendar / vCard variant of the quote-aware splitter. A " toggles the quoted state and there is NO backslash escaping of the quote (RFC 5545 3.1.1 / RFC 6350 3.3 QSAFE-CHAR excludes DQUOTE, so a " always opens or closes a run). This is deliberately simpler than splitTopLevel, which honours HTTP structured-field backslash-quote escapes; the two are NOT interchangeable. Accepts any single-character separator.

b.structuredFields.splitUnquoted('a;b="x;y";c', ";");
// returns ['a', 'b="x;y"', 'c']

b.structuredFields.stripDoubleQuotes(s) #

stable0.15.13

Strip ONE layer of surrounding " from s when both ends are a double quote (length ≥ 2), otherwise return s unchanged. The plain DQUOTE unwrap used by the iCal / vCard parsers for a quoted parameter value — no backslash-escape processing (RFC 5545 3.1 / RFC 6350 5: a quoted param value is QSAFE-CHAR, which excludes DQUOTE, so there is nothing to unescape). Distinct from unquoteSfString, which decodes HTTP structured-field \" / \\ escapes.

b.structuredFields.stripDoubleQuotes('"a;b"');
// returns 'a;b'

b.structuredFields.stripDoubleQuotes('plain');
// returns 'plain'

b.structuredFields.unfoldHeaderContinuations(value) #

stable0.15.13

Collapse RFC 5322 folding whitespace — a CRLF (or bare LF) followed by one or more spaces/tabs — back to a single space, reversing the line folding a header value may carry in transit. Used before parsing DKIM / ARC / Authentication-Results tag lists, where a folded b= / bh= value must be rejoined before its base64 is read.

b.structuredFields.unfoldHeaderContinuations("v=DKIM1;\r\n  k=rsa");
// returns "v=DKIM1; k=rsa"

b.structuredFields.parseKeyValuePiece(piece, kvSep?, lowerKey?) #

stable0.15.13

Parse ONE already-split list piece into a { key, value } pair: key is the text before the first kvSep (default "="), trimmed and — unless lowerKey is false — lower-cased; value is the raw remainder (the caller trims / unquotes / sf-string-parses it per its own grammar). A piece with NO kvSep is a "bare" item — value is null — which the caller handles per its grammar (skip it, or treat as a flag-style directive).

The shared per-pair step behind every key=value list parser: the naive parseTagList loop AND the quote-aware parsers that first splitTopLevel then parse each piece (Cache-Control directives, Client-Hints brand params, Content-Type parameters). lowerKey:false serves the rare grammar whose key is case-sensitive (a verbatim tag-list passthrough).

b.structuredFields.parseKeyValuePiece("Max-Age=60");   // → { key: "max-age", value: "60" }
b.structuredFields.parseKeyValuePiece("no-store");      // → { key: "no-store", value: null }
b.structuredFields.parseKeyValuePiece("Key=a=b", "=", false); // → { key: "Key", value: "a=b" }

b.structuredFields.parseTagList(input, opts?) #

stable0.15.13
{
  sep:          string|RegExp,  // entry separator. default: ";"  (MTA-STS uses /\r?\n/)
  kvSep:        string,         // key/value separator. default: "="  (MTA-STS uses ":")
  unfold:       boolean,        // collapse CRLF+WSP folds to a space first (DKIM FWS). default: false
  stripValueWs: boolean,        // strip all whitespace inside each value (DKIM/ARC FWS). default: false
  lowerKey:     boolean,        // lower-case each key. default: true (set false to preserve case)
}

Parse a NAIVE delimited keyvalue tag list into an ordered array of [key, value] pairs. This is the non-quote-aware sibling of splitTopLevel: it is for grammars whose RFC forbids the DQUOTE structured-string form, so a bare split is correct and a quote-aware walk would be wrong — DKIM (RFC 6376 §3.2), DMARC (RFC 7489 §6.4), ARC (RFC 8617 §4), BIMI (RFC 9091 §4), and the MTA-STS policy grammar (RFC 8461, line/colon delimited).

Pairs are returned (not a map) so a caller whose grammar permits a repeated key — MTA-STS mx: lines list one host each — keeps every occurrence; a caller wanting last-wins map semantics folds the pairs into a plain object itself. Order is preserved, so a caller that throws on a malformed value throws at the same point it would have mid-loop.

Entries that are empty after trimming, or carry no kvSep, are skipped (matching every shipped parser's prior behavior).

b.structuredFields.parseTagList("v=DKIM1; k=rsa; p=MIGf");
// → [ ["v", "DKIM1"], ["k", "rsa"], ["p", "MIGf"] ]

b.structuredFields.parseTagList("version:STSv1\nmx:a.example\nmx:b.example",
  { sep: /\r?\n/, kvSep: ":" });
// → [ ["version","STSv1"], ["mx","a.example"], ["mx","b.example"] ]

b.structuredFields.parseKeyValuePieces(pieces, startIndex?, kvSep?, lowerKey?) #

stable0.15.13
{
  startIndex: number,   // default: 0 — first piece treated as a key/value pair
  kvSep:      string,   // default: "=" — key/value separator within a piece
  lowerKey:   boolean,  // default: true — lower-case each parsed key
}

Iterate an already-split list of structured-field pieces, trimming each, dropping empties, and parsing the survivors into { key, value } records via parseKeyValuePiece. startIndex skips a leading non-pair token (the media type in a Content-Type, the brand in a Sec-CH-UA member); kvSep overrides the default "="; lowerKey:false preserves key case for a case-sensitive grammar.

The split is left to the caller because the boundary discipline differs by grammar — splitTopLevel honours quotes/parens for RFC 8941 lists, while a plain String(value).split(";") is right where inner separators cannot be quoted. Per-piece dispatch (unquote, numeric coercion, poisoned-key drops) stays with the caller; this owns only the uniform iterate-trim-skip-parse spine the parsers shared verbatim.

var kvps = b.structuredFields.parseKeyValuePieces(
  b.structuredFields.splitTopLevel("max-age=600, immutable", ","));
// → [ { key: "max-age", value: "600" }, { key: "immutable", value: null } ]

b.structuredFields.forEachKeyValue(kvps, handler) #

stable0.15.13

Consume the { key, value } records from parseKeyValuePieces: skip the bare entries (those with a null value — a key that carried no separator), trim each surviving value, and invoke handler(key, trimmedValue, index). The mirror of parseKeyValuePieces on the consuming side — that primitive owns the parse spine, this owns the iterate-skip-bare-trim spine every header parser repeated verbatim before dispatching.

Per-key dispatch (sf-string unquoting, numeric coercion, poisoned-key drops, building a typed result) stays in the handler — a handler that returns skips the current entry, exactly like a continue. Parsers that instead treat a bare key as meaningful (a value-less directive) iterate the records directly rather than calling this.

var b = require("blamejs");

var out = {};
var kvps = b.structuredFields.parseKeyValuePieces("a=1; b=2".split(";"));
b.structuredFields.forEachKeyValue(kvps, function (key, value) {
  out[key] = value;
});
// out → { a: "1", b: "2" }

b.structuredFields.refuseControlBytes(value, opts) #

stable0.9.0
{
  ErrorClass: Function,  // required — error class to throw
  code:       string,    // required — error code (e.g. "foo/bad-header-value")
  label:      string,    // required — operator-readable label for the value
  allowHt:    boolean,   // default: true — permit ASCII HT (folding ws)
}

Scan a header value for C0 control characters (codepoints < 32) and DEL (127) and throw via the supplied error class when any appear. ASCII HT (9) is permitted as folding-whitespace — RFC 9110 §5.5 lists HT as a structural separator that downstream .trim() then absorbs.

Must run on the RAW value BEFORE any .trim() call. Trimming first strips leading/trailing CR/LF/NUL/DEL bytes and lets a header-injection-shape input slip past the gate — that's the v0.8.90 b.mail.requireTls.parseTlsRequiredHeader bug class.

b.structuredFields.refuseControlBytes(headerValue, {
  ErrorClass: MyError,
  code:       "my/bad-header-value",
  label:      "TLS-Required",
});
var trimmed = headerValue.trim();   // safe — the gate ran on raw

b.structuredFields.unquoteSfString(s) #

stable0.9.0

Strip RFC 8941 §3.3.3 quoted-string wrapping from a piece value, handling \\ and \" backslash-escapes. Returns the unwrapped string when the piece is "..."-shaped; returns the input unchanged otherwise (tolerates bare-token values some upstream proxies emit). Returns null for an unterminated "... shape so callers can surface a parser-level error.

b.structuredFields.unquoteSfString('"hello, world"');
// → 'hello, world'

b.structuredFields.unquoteSfString('"a\\"b\\\\c"');
// → 'a"b\c'

b.structuredFields.unquoteSfString('bare');
// → 'bare'  (operator-supplied bare-token form passes through)

b.structuredFields.unescapeSfStringBody(body) #

0.15.12

Undo the RFC 8941 §3.3.3 quoted-string backslash-escapes from the BODY of an sf-string (the bytes BETWEEN the surrounding double quotes). Only \\\\ and \\" are legal escapes; every other backslash is literal.

This is a single left-to-right scan, NOT two chained .replace() passes. The two-pass form (.replace(/\\\\/g,"\\").replace(/\\"/g,'"'), in either order) is not equivalent to a single decode: whichever pass runs first can rewrite a backslash the other escape sequence legitimately owns, so a lone escaped backslash (\\\\) decodes to two backslashes instead of one. The single pass consumes each escape exactly once. Non-string input passes through unchanged.

b.structuredFields.unescapeSfStringBody('a\\"b\\\\c');
// → 'a"b\c'

b.structuredFields.containsControlBytes(value, opts?) #

stable0.9.0
{
  allowHt: boolean,   // default true — permit ASCII HT
}

Predicate variant of refuseControlBytes for defensive request-shape readers that RETURN DEFAULTS rather than throw (the framework's third validation tier). Returns true when the RAW value contains any C0 / DEL byte (ASCII HT permitted by default as folding-whitespace).

function parseChallenge(headerValue) {
  if (b.structuredFields.containsControlBytes(headerValue)) return null;
  // ...safe to .trim() / .slice() now
}

b.structuredFields.parse(input, type, opts?) #

stable0.12.54
{
  ErrorClass?: Function,   // typed error class (default: native Error with .code)
}

Parse an RFC 8941 Structured Field value. type is "item", "list", or "dictionary". Returns the value model: an item is { value, params } (params is a Map); a list is an array of items / inner lists; a dictionary is a Map. Tokens, byte sequences, dates, and display strings come back as SfToken / SfByteSequence / SfDate / SfDisplayString instances so they stay distinct from plain strings and integers. Strictly enforces the grammar — integer / decimal digit caps, printable-ASCII strings, canonical base64, no trailing characters — and throws on any malformed input (pass opts.ErrorClass for a typed error).

b.structuredFields.parse("a=1, b=(x y);q=2", "dictionary");
// → Map { "a" => { value: 1, params: Map{} },
//         "b" => { items: [...], params: Map{ "q" => 2 } } }

b.structuredFields.serialize(value, type, opts?) #

stable0.12.54
{
  ErrorClass?: Function,   // typed error class (default: native Error with .code)
}

Serialize a value model back to an RFC 8941 field value (the inverse of parse). type is "item", "list", or "dictionary". Numbers serialize as Integers when integral and Decimals (rounded to 3 fractional digits) otherwise; wrap Tokens / byte strings in SfToken / SfByteSequence. Throws on values outside the RFC's ranges or grammar (out-of-range integers, non-printable string characters, invalid tokens / keys).

var sf = b.structuredFields;
sf.serialize({ value: new sf.Token("gzip"), params: new Map([["q", 1]]) }, "item");
// → "gzip;q=1"

Last updated 2026-08-08T16:39:49.652Z by seeder.