feat(api)!: select rules by Rule id + the cookie conformance domain - #4
feat(api)!: select rules by Rule id + the cookie conformance domain#4parkrevil wants to merge 15 commits into
Conversation
…ixture Structural cleanup, zero behavior change (773 tests unchanged throughout): - Extract the clause->rule conformance catalog (types, registry, disposition-enums, per-standard modules) out of src/standards/ into a new src/catalog/ layer. standards/ was fusing the normative-source corpus with the catalog, and the catalog's Rule-enum reference was the tree's only bidirectional module edge (core <-> standards). Now standards/ is a pure L0 corpus and the module graph is a single acyclic DAG (standards -> core -> catalog/http -> rules -> api). - Break the standards/interfaces <-> standards/types file-level import cycle by defining StandardDocument beside its member interfaces. - Hoist the duplicated origin.test HttpTarget fixture (69 spec files) into src/testkit/target.ts, matching the existing testkit fixture pattern; per-spec run() helpers stay local (rule-coupled, no shared abstraction). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…keletons Separated-TDD Pass 1 for the cookie conformance domain (PLAN-COOKIE.md); specs + skeletons only, rule judging logic is Pass 2. - src/catalog/cookie.ts — 51 clauses (32 rule-bearing across sections A-D, 18 untestable residue, no-combine), implementing the two-axis model. - addressee axis — new Addressee enum + Clause.addressee; registry.spec.ts scopes the MUST->Fail / SHOULD->Warn / Unmarked->Warn invariants to server clauses and adds the user-agent invariant (discard->Fail, alter->Warn, severityNote required). Pre-existing clauses default to Server via build.clause(), so no other catalog module changed. - 32 Rule ids; 32 flat rule skeletons returning a NotImplemented sentinel; 32 colocated specs from the fires/silent table + spec vectors + do-not-flag negatives; the consumer-simulation kit as types-only (cookie-sim.ts). - corpus — RFC 6265bis modeled as a Draft LivingDocument (no RFC number yet). RED verified: 182 cookie assertions fail against the sentinel (verdict mismatch, never a compile/import error); 775 pre-existing tests stay green; typecheck / lint / oxfmt clean; the module DAG stays acyclic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation Implements the judging logic that turns the Pass-1 (RED) cookie specs green; the locked specs/catalog were not touched (one Pass-1 fixture typo excepted). - src/rules/kit/cookie-sim.ts — the consumer simulation (RFC 6265bis 5.6 parse + 5.7 selection); the request-context-dependent steps stay unmodelled per the plan's 0.5. - src/rules/kit/cookie-grammar.ts, cookie-rule.ts — the raw 4.1 grammar splitter and the defineCookieRule factory. - src/normative/ — one-concept predicates: the lenient 5.1.1 cookie-date parser, 5.1.2 host canonicalization, 5.1.3 domain-match (IP-guarded), 5.1.4 default-path, a versioned public-suffix snapshot, and the cookie-octet / av-octet / CHAR octet classes; http-date.ts gained isImfFixdate (additive). - the 32 rule bodies across grammar Fail / server SHOULD / UA discard / UA alter. - fix a Pass-1 fixture typo in alter-lifetime-advisory.spec.ts (a Max-Age too small to exceed a 10-day limit) — flagged by the implementer, corrected by the contract owner rather than distorting the implementation. 962 tests pass, 0 fail; typecheck / lint / oxfmt clean; the per-file 95% coverage gate is met; the module DAG stays acyclic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…view Green tests passed, but a three-engine review (codex/grok/Claude) plus a re-review found 11 real divergences from RFC 6265bis that the ASCII / valid- input fixtures had missed. Each is fixed RED-first — a failing test added that exposes the wrong verdict, then the implementation. - octet vs UTF-16: the 4096 / 1024 size gates measured JS string length, not octets, so a multibyte value could slip past. New normative/octet-length.ts; every gate (cookie-sim, discard-oversize-4096, alter-attribute-dropped-1024, alter-path-rescoped, and the alter-max-age/expires eligibility filters) now measures octets. - cookie-date: the 5.1.1 day/year/time productions permit a trailing non-digit run; the anchored regexes wrongly rejected legal forms like the glued `10:18:14GMT` and `1st Jan 2020 ...`. - reachesPrefixStep: added the 5.7 Domain-CHAR and public-suffix gates that precede the prefix steps, so a cookie discarded earlier no longer mis- attributes the discard to the prefix rule. - cookie-value-grammar: scan the WSP-trimmed value (BWS around `=` belongs to forbidden-wsp, not the value octet check); internal SP still fails. - alter-nameless-stored: an empty `Set-Cookie:` is discarded, not stored. - host-prefix-contract: select the last Path (findLast), matching 5.7. - cookie-host: reject R-LDH (`xn--`) labels as Fake A-labels, yielding an indeterminate verdict (honest reduced scope; ashward does not implement IDNA). - forbidden-wsp: an all-WSP cookie-av is a 4.1.1 deviation. 979 tests pass, 0 fail; typecheck / lint / oxfmt clean; per-file 95% coverage; module DAG stays acyclic; the locked catalog/spec contract was not touched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-safe
Two issues in the cookie host canonicalizer, found in a final-pass review:
- A prior round rejected EVERY `xn--` label to avoid accepting a Fake A-label,
which over-cautiously turned a genuine A-label (e.g. `xn--bcher-kva`, real
punycode) into an indeterminate verdict. Use real UTS-46 ToASCII via the
`URL` global: a valid A-label now yields a definite verdict, a Fake A-label
(`xn--bogus`) makes `new URL` throw and the rows stay indeterminate.
- `new URL('http://' + host + '/')` silently reinterprets a host that carries
URL-authority/path syntax — `evil@example.com` parses as userinfo and returns
hostname `example.com`, wrongly giving a bogus host a definite verdict. Guard
the host against any non-LDH octet (and `_`, and raw non-ASCII — the request
host is already ToASCII via resolve-target) BEFORE the URL parse.
canonicalizeHost verified directly across 16 host shapes (valid / fake
A-labels, underscore, non-xn-- R-LDH, the URL-delimiter misparse set, leading
hyphen, IPv4/IPv6, uppercase). 981 tests pass, 0 fail; typecheck / lint /
oxfmt clean; per-file 95% coverage; module DAG acyclic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…icalization Follow-up review of the host canonicalizer: - Route the IP-literal branch through the same URL parse as the DNS branch, so a malformed literal (`[garbage`, `evil:garbage`, an out-of-range dotted quad) is rejected to indeterminate instead of being passed through by the loose shape check. - Enforce RFC 5890's 63-octet LDH-label limit. - Document the deliberate reduced scope: canonicalizeHost reuses the WHATWG normalizer that resolve-target already applies to derive the request host (so it is idempotent on every host reaching it through the public API), not a bit-exact IDNA2008 validator — an A-label whose punycode decodes to a disallowed code point (an emoji) and a raw U-label (which never arrives raw) are out of scope for a black-box oracle that never probes such hosts. 983 tests pass, 0 fail; typecheck / lint / oxfmt clean; per-file 95% coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the RFC 6265bis cookie section to README.md / README.ko.md (producer conformance §4 + consumer-consequence §5, the two-axis model) and a minor changeset for the new domain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lic API Two defects a consumer's field report surfaced, both confirmed by execution before and after the fix. - A run that judged zero clauses reported ok. `ashward(url, [])` — or any caller whose `ALL_RULES.filter(...)` happens to select nothing — returned `ok() === true` with an empty result set, i.e. a green gate that verified nothing. `resolveOk` now treats an empty result set as not-ok unconditionally, the same "a conformance gate that ran nothing must not go green" principle ok-policy already applied to connectivity failures, and `assertOk` throws a message that says so instead of an empty failure list. - `cookieSim` was unreachable from the public API. Four shipped cookie rules read `HttpRuleContext.cookieSim`, but `AshwardOptions` only threaded `reflect`, so a real supercookie (`Set-Cookie: a=b; Domain=com`) was reported indeterminate to every consumer while the rule itself judges it fail. Threaded end-to-end through `runHttp`, mirroring `reflect`, preserving the documented three-state `ageLimitDays`. README / README.ko document the new option; the cookie changeset is amended. 989 tests pass, 0 fail; typecheck / lint / oxfmt clean; per-file 95% coverage; module DAG acyclic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`ashward(url, [Rule.OriginReflection])` now works. The name a caller could say (`Rule.X`) was not the thing they could pass (`rules.x`), so the enum was useful only for comparing results and every selection needed the other vocabulary. The selection parameter becomes `readonly Rule[]`, resolved internally by `resolveRules` in `rules/all.ts`, which de-duplicates by id (first wins — the engine maps the selection with no uniqueness check, so a repeat would put two results under one `ruleId`) and throws RangeError on an id with no definition, since ids arrive as data from config files and CLI flags. BREAKING: `ALL_RULES` becomes `ALL_RULE_IDS`; the `rules.*` namespace, `RuleDef`, `HttpRuleContext`, `HttpTarget` and `ProbeFn` are no longer exported. Passing a custom `RuleDef` — including substituting one under a shipped id, which `ashward`'s doc comment advertised — goes with them; that capability is removed deliberately, not because it did not work. Its test moves to `runHttp`, which keeps the `RuleDef[]` seam and is the right layer for "which path did the probe hit". `AshwardOptions`, `CookieSimConfig` and `ReflectMode` are now exported. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit replaced `ALL_RULES` with `ALL_RULE_IDS`, which was a second roster under a second name: its own spec asserted it equals `Object.values(Rule)` element-for-element, so the two were provably the same list. Shipping both is the exact defect this work exists to remove — one vocabulary means one. The default selection is now `Object.values(Rule)`, and a caller scoping a run writes `Object.values(Rule).filter(id => id !== Rule.X)`. Nothing is lost: the roster-completeness spec still pins every `Rule` member to exactly one shipped definition, so the enum cannot drift from what actually runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding `Rule`-id selection left two overlaps that were mine, not pre-existing: - `api/rule-selection.spec.ts` tested the public entry alongside `api/ashward.spec.ts`, including a duplicate empty-selection case. Merged into `ashward.spec.ts`; one file per public entry point. - `runHttp` had integration specs in two directories after the custom-RuleDef test moved to `http/`. `core/engine/run.integration.spec.ts` imported `runHttp` from `http/` anyway, so both now live in `http/run.integration.spec.ts` beside the binder they exercise. No test is dropped: 3 runHttp integration tests and 7 ashward tests, all still asserting what they did before. Verified separately that the four exported-but-locally-used symbols the scan reports (Rfc9112ClauseId, FramingRuleSpec, DecodedBody, StandardLocator) are unchanged from before this branch — none is residue from removing the RuleDef surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Rfc9112ClauseId`, `FramingRuleSpec`, `DecodedBody` and `StandardLocator` were exported but referenced only inside their own module. Each is the odd one out among its own siblings, which is what makes them leftovers rather than a deliberate surface: - every other catalog ClauseId is cited 11-26 times by rule files; rfc9112 is cited 0, because its two framing rules hand-author their `normative` arrays instead of calling `refsFor` - eight of the nine rule kits keep their `*Spec` interface module-private; framing-rule did not - `DecodedBody` is the only exported type alias under `http/decode` - `StandardLocator` is the only export in `standards/interfaces.ts` with no external reference These predate the Rule-id selection work — verified by running the same scan against 619490f, which reports the identical four. Being pre-existing is not a reason to leave them, so they go now rather than being recorded as known residue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A working design document that `git add -A` swept into 9fb65cb along with the rule-selection change. It does not belong in the package. Its measured baseline and the reasoning behind the coverage/selection design are carried forward in HANDOFF.md, together with the two sections it got wrong: the severity redesign (scoped to 90 mappings when the real gap is one bit on 18 cookie user-agent clauses) and the placement of coverage() in src/catalog/ (rules -> catalog is 91 files, the reverse is 0, so that direction is a cycle). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The package is 0.x; a major changeset would publish 1.0.0 and read as an API stability claim while coverage(), Report.counts and the Severity redesign are still open. Breaking changes stay breaking — they ship as 0.3.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parkrevil
left a comment
There was a problem hiding this comment.
Review summary
I am requesting changes based on six reproducible correctness issues. I reviewed these against the current PR head (8042fe8), the PR description and commit history, the repository's own rule-ownership conventions, and the current RFC 6265bis editor's draft. I did not modify the branch.
1. Raw Set-Cookie octets are changed by UTF-8 decoding
Affected code: src/http/decode/head-parse.ts:33, src/normative/octet-length.ts:9, src/rules/kit/cookie-sim.ts:86-88,210-218, src/rules/discard-oversize-4096.ts:18-21
parseResponseHead() decodes every field line with the default TextDecoder. Invalid UTF-8 octets become U+FFFD, after which octetLength() UTF-8-encodes the changed string. This alters both lengths and values before the cookie rules inspect them.
Reproduction through the complete rule execution path:
wire Set-Cookie value: "a=" + 1366 × 0xFF
actual name + value length: 1367 octets
discard-oversize-4096 result: Fail
Each 0xFF becomes U+FFFD and is subsequently counted as three UTF-8 octets, so a cookie far below 4096 octets is reported as discarded for exceeding the limit. The same problem affects the 1024-octet attribute limit.
This also changes semantics, not just lengths. With Set-Cookie: a=b; Domain=K (wire bytes E2 84 AA) and target host k, Unicode toLowerCase() maps K to ASCII k; both discard-domain-char and discard-domain-mismatch return Pass. Those original octets are outside CHAR, so the cookie must be discarded at the Domain CHAR step.
RFC 6265bis requires parsing the individual octets without decoding them, and RFC 9110 says obs-text (%x80-FF) is opaque data:
- https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#section-5.6
- https://www.rfc-editor.org/rfc/rfc9110.html#section-5.5
This contradicts the cookie implementation's stated octet-exact behavior and produces both false Fail and false Pass results.
2. The reduced PSL produces both false Pass and false Fail verdicts
Affected code: src/normative/public-suffix.ts:16-56, src/rules/discard-public-suffix.ts:33-34, src/rules/discard-host-prefix.ts:17-22
The reduced list is deliberate, but its documented claim that omissions are conservative and can never create false Fail results is incorrect. publicSuffixConfigured: true exposes definitive Pass/Fail results while the implementation recognizes only 35 literal suffixes and supports neither wildcard nor exception rules.
Reproduced false Pass:
target host: foo.xyz
Set-Cookie: a=b; Domain=xyz
cookieSim: { publicSuffixConfigured: true }
discard-public-suffix result: Pass
expected: Fail (xyz is an ICANN public suffix)
Reproduced false Fail:
target host: xyz
Set-Cookie: __Host-a=1; Secure; Path=/; Domain=xyz
cookieSim: { publicSuffixConfigured: true }
discard-host-prefix result: Fail
expected: Pass
At RFC 6265bis section 5.7 step 9, a public-suffix Domain identical to the request host is cleared. The cookie therefore becomes host-only and satisfies the __Host- contract. Because xyz is absent from the reduced set, that clearing never occurs. co.uk works as the control case only because it is hard-coded.
Wildcard PSL rules fail similarly (*.ck / !www.ck). Sources:
- https://publicsuffix.org/list/public_suffix_list.dat
- https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#section-8.9
Thus this is not merely incomplete coverage: the public option can return incorrect conclusions in both directions.
3. The PR claims all-Skip runs are blocked, but they remain green
Affected code: src/core/report/ok-policy.ts:51-52, src/core/assert/assert-ok.ts:18-25
The current PR description states that ranNothing was widened to cover an empty result set or all-Skip results. The accepted follow-up design also distinguishes this from treating individual skips as failures. However, the implementation remains:
return results.length === 0;Reproduction:
results: [{ verdict: Skip, reason: HeaderAbsent }]
report.ok(): true
assertOk(report): does not throw
The history matters here: commit 8c36639 originally intended to block empty results only, and the existing test still explicitly expects a Skip-only report to be OK. The later all-Skip requirement was designed but not implemented, while the current PR description presents it as shipped. Therefore this is a concrete mismatch between the PR's declared scope and the code, rather than evidence that every individual Skip should fail.
4. Legal HTAB OWS after ; is reported as an invalid cookie attribute
Affected code: src/rules/cookie-av-shape.ts:10-25, src/rules/kit/cookie-grammar.ts:11-27
Reproduction:
Set-Cookie: a=b;\tSecureActual results:
cookie-av-shape: Fail
forbidden-wsp: Pass
RFC 6265bis defines this boundary as ";" OWS cookie-av, and RFC 9110 defines OWS as zero or more SP/HTAB octets. rawAvSegments() retains the leading HTAB, and cookie-av-shape then tests it as though it were part of cookie-av; HTAB is not an av-octet, so the valid OWS is falsely reported as a producer grammar violation.
- https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html#section-4.1.1
- https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.3
The adjacent forbidden-wsp implementation already recognizes leading WSP after ; as legal OWS, confirming the intended ownership at this boundary.
5. Later section 5.7 discard rules fire after an earlier step has already aborted
Affected code: src/rules/discard-samesite-none-insecure.ts:20-34, src/rules/discard-nameless-prefix-value.ts:16-24, src/rules/kit/cookie-sim.ts:276-309, src/rules/discard-secure-prefix.ts:21-30
Several consequence rules report a later discard cause even when the cookie never reaches that RFC step.
Examples reproduced through rule.run():
target: site.example
Set-Cookie: a=b; Domain=other.example; SameSite=None
actual: discard-domain-mismatch Fail + discard-samesite-none-insecure Fail
expected attribution: Domain mismatch only; step 10 aborts before step 19
Set-Cookie: =__Secure-a; SameSite=None
actual: discard-nameless-prefix-value Fail
expected attribution: SameSite=None without Secure aborts at step 19 before step 22
Set-Cookie: __Secure-a=1; SameSite=None
actual: SameSite rule Fail + Secure-prefix rule Fail
expected attribution: step 19 aborts before the prefix step
The final conclusion that the cookie is discarded remains true, but the named rule attribution is false. This is inconsistent with the repository's stated intent: discard-oversize-4096 explicitly excludes cookies aborted by the preceding CTL step, prefix-rule comments say they count only cookies that reach the prefix step, and reachesPrefixStep() was introduced specifically to model earlier Domain/PSL aborts. The gating is only partial.
6. Two different malformed fields are reported as duplicate cookie names
Affected code: src/rules/no-duplicate-name-across-fields.ts:16-19
Reproduction:
Set-Cookie: foo
Set-Cookie: barActual results:
cookie-pair-equals: Fail
no-duplicate-name-across-fields: Fail
Neither field has a section 4.1.1 cookie-pair or cookie-name. The producer duplicate-name rule nevertheless feeds both values through the section 5.6 consumer parser, which maps every equals-less value to name === ""; the two unrelated malformed fields are then treated as duplicate names.
The adjacent producer rules cookie-name-token and cookie-name-nonempty deliberately exclude equals-less fields because cookie-pair-equals owns that malformed shape. The duplicate-name rule should follow the same producer-rule boundary rather than deriving a synthetic empty name from the consumer parser.
Verification
- Existing suite:
993 pass / 0 fail tsgo --noEmit: pass- type-aware lint: pass
- build: pass
- Each finding above was separately reproduced against the current PR head through the relevant rule/report execution path.
report.ok() returned true and assertOk() returned silently when every selected rule skipped, so a CI gate went green having judged nothing. A skip is a result but not a judgment — the rule reached the response and found its precondition absent — so an all-skip run is the same vacuum as running no rules, which ranNothing already refused to green. Widening that one predicate closes both gates: assertOk never calls resolveOk, but both call ranNothing. [].every() is true, so the empty case falls out of the same check rather than needing its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
96cf7e5 to
07636a4
Compare
Summary
Two things ship together:
Rule-only selection.ashward(url, [Rule.OriginReflection])now works. Previously the name you could say (Rule.X) was not the thing you could pass (rules.x, aRuleDef). Breaking:ALL_RULES,ALL_RULE_IDS, therules.*namespace, andRuleDef/HttpRuleContext/HttpTarget/ProbeFnare removed. Public surface is 9 names.Also: an empty result set no longer passes (
ranNothing), andcookieSimis wired to the public options.Release
Two pending changesets, both
minor→ 0.3.0. The selection change is breaking, but the package is 0.x andcoverage(),Report.countsand theSeverityredesign are still open — 1.0.0 would read as a stability claim it can't back.Verification
993 pass / 0 fail· coverage 100% line / 99.13% branch (gate 95%) ·tsgo --noEmitclean ·oxlint --type-awareclean ·oxfmt --checkclean · build clean. CIverifygreen. Rebased ontomain@ 0.2.0.Not merge-ready: six correctness findings from review reproduce against this head and are unfixed. See the review thread.
🤖 Generated with Claude Code