RFC 9651 Structured Fields
Small set of cross-primitive helpers for parsing RFC 9651 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 9110 §5.6.4 quoted-string parameter lists all need the same primitive: walk a comma-or-semicolon-delimited list while tracking RFC 9651 §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 9651 §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, opts?) #
{
comments: boolean, // default false; skip RFC 5322 §3.2.2 (...) comments
}
Split s on top-level occurrences of sep (one of , or ;), respecting RFC 9651 §3.3.3 quoted-string boundaries with backslash-escape. Returns the array of trimmed-by-caller pieces.
opts.comments: true additionally skips a (...) comment whole, nesting and backslash escapes included: RFC 5322 §3.2.2 places comments between the tokens of a structured field and admits both a separator and a quote inside one, so a=b (x; y); c=d is two fields rather than three. It is OFF by default, and belongs only to callers reading that grammar: RFC 9651 has no comments, and RFC 6265 §4.1.1 makes ( an ordinary character in a cookie path, where reading one as a comment swallows every attribute that follows. Parentheses inside a quoted string are content either way.
Defensive: a value ending inside an unterminated quoted string, comment or escape drops the trailing piece without throwing (the caller's grammar treats the malformed input as missing rather than closing the run for the sender).
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.splitTopLevel("a=b (x; y); c=d", ";", { comments: true });
// → ['a=b (x; y)', ' c=d']
b.structuredFields.endsInsideQuotedString(s, opts?) #
{
comments: boolean, // default false; skip RFC 5322 §3.2.2 (...) comments
}
Report whether s ends with a run still open: a quoted string, an RFC 9651 §3.3.3 backslash escape, or, under opts.comments, a comment. splitTopLevel drops the piece it cannot terminate, so a caller reading that array cannot tell an absent parameter from one an unterminated run swallowed. The two answer the same question from one scan, so a shape the split drops is a shape this reports. Ask this before treating a missing piece as unwritten: a grammar whose default for an absent value is weaker than the value being smuggled past has to refuse the field instead. A charset=utf-7 (unterminated read as us-ascii with no parameters is that default standing in for a value the sender wrote.
Under opts.comments: true a quote inside a (...) comment is ordinary text, as RFC 5322 §3.2.2 makes it, so a=b (note " here) ends inside nothing. Pass the same option here as to splitTopLevel, because a caller asking where a quoted string runs and a caller asking where a field ends must not disagree about it. It is off by default: an HTTP field's ( is an ordinary character.
b.structuredFields.endsInsideQuotedString('a="1"; b=2');
// → false
b.structuredFields.endsInsideQuotedString('a="1; b=2');
// → true
b.structuredFields.endsInsideQuotedString('a="1\\"');
// → true
b.structuredFields.splitUnquoted(s, sep) #
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 honors 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) #
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) #
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?) #
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?) #
{
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 key 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 9989 §4.8), ARC (RFC 8617 §4), BIMI (draft-blank-ietf-bimi), 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).
A RegExp sep is screened with b.guardRegex.assertSafe on each call, which throws GuardRegexError regex/unsafe-pattern for a pattern it refuses.
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.recordVersionMatches(record, name, value, opts?) #
{
end: "space" | "semicolon", // default "semicolon"; "space" ends the version at SP or the end of the record
wspAroundEquals: boolean, // default false; allow *WSP on both sides of "=" (DMARC)
nameIgnoresCase: boolean, // default false; compare `name` case-insensitively (the quoted `"v"` of DMARC / MTA-STS / SPF)
valueIgnoresCase: boolean, // default false; compare `value` case-insensitively (SPF's quoted `"v=spf1"` only)
requireNextField: boolean, // default false; refuse a record that ends after the version (MTA-STS, TLS-RPT)
}
Does a DNS TXT policy record begin with the version field name=value, ended where its grammar ends it? Mail policy records select themselves this way before any other field is read: SPF (RFC 7208 §4.5) takes a record whose version section is exactly v=spf1, ended by a space or the end of the record; DMARC (RFC 9989 §4.8), MTA-STS (RFC 8461 §3.1) and TLS-RPT (RFC 8460 §3) end the version at *WSP ";". A record that fails the check is discarded, so v=spf10, v=DMARC1x and x v=STSv1 are not policy records.
Case is per record type, because their ABNF differs and a uniform rule is wrong in one direction or the other. A quoted ABNF literal folds (RFC 5234 §2.3); a value spelled in hex does not. SPF writes the whole version as "v=spf1", so both halves fold. DMARC and MTA-STS write the name as "v" and the value in hex, so the name folds and the value is exact. TLS-RPT writes even the name as %x76, so neither folds. The caller states which, rather than this function guessing from the value.
b.structuredFields.recordVersionMatches("v=spf1 -all", "v", "spf1", { end: "space" }); // → true
b.structuredFields.recordVersionMatches("v=spf10 +all", "v", "spf1", { end: "space" }); // → false
b.structuredFields.recordVersionMatches("v = DMARC1; p=reject", "v", "DMARC1",
{ wspAroundEquals: true, nameIgnoresCase: true }); // → true
b.structuredFields.parseKeyValuePieces(pieces, startIndex?, kvSep?, lowerKey?) #
{
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 honors quotes/parens for RFC 9651 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) #
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) #
{
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. Codes 0x80-0xff are permitted as obs-text (RFC 9110 §5.5): Node exposes a header value as a latin1 string, so those codes are the bytes of a UTF-8 sequence.
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) #
Strip RFC 9651 §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) #
Undo the RFC 9651 §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?) #
{
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). Codes 0x80-0xff are permitted as obs-text, the same as refuseControlBytes.
function parseChallenge(headerValue) {
if (b.structuredFields.containsControlBytes(headerValue)) return null;
// ...safe to .trim() / .slice() now
}
b.structuredFields.parse(input, type, opts?) #
{
ErrorClass?: Function, // typed error class (default: native Error with .code)
}
Parse an RFC 9651 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?) #
{
ErrorClass?: Function, // typed error class (default: native Error with .code)
}
Serialize a value model back to an RFC 9651 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"