diff --git a/docs/mpp-expires-rfc3339-conformance.md b/docs/mpp-expires-rfc3339-conformance.md new file mode 100644 index 000000000..300ccf59a --- /dev/null +++ b/docs/mpp-expires-rfc3339-conformance.md @@ -0,0 +1,532 @@ +# RFC 3339 `expires` conformance: pay-kit SDK parse paths + +Divergence results from driving every pay-kit `expires` parse path against the RFC 3339 `date-time` +vectors vendored at `harness/vectors/mpp-protocol/expires.json` (issue #111). + +- Oracle: RFC 3339, pinned text sha256 `9ab2b8864a85dca73a88f49b0927bc7bc85f596926e4fd1890905777924e700a` (35,064 B). +- Vectors: `harness/vectors/mpp-protocol/expires.json`, version `2.0.0`, **124 scenarios** (54 ACCEPT, + 70 REJECT), 36,480 B, sha256 `613fdb3f31faffd65db0bdd44693112e5c8c2b99655455f3549900afb19696cc`. +- Parse paths: go, lua, php, python, ruby, rust, typescript; typescript has two call sites, so eight. +- Operation `expires.parse`. Verdict axis ACCEPT or REJECT. Cells: 124 x 8 = 992. +- Source lines resolve at `48ea8aa43fb555d55fd9c3365a4a045ea96f1618`, this branch merged with main + `c143bfabef82dbdf01dcff7a52ab29bf166d7eb4`. +- #286 (`parseRfc3339`) is not on main; applied to the worktree it takes both TypeScript rows to 6 (leap + seconds only). +- Counts were re-run after review: `year_0000` became `year_0001` (#315 and #320 reject year 0 on purpose), + and the go runner reads its verdict from `time.Parse`. php was executed on this run, not carried. +- Sources, upstream shas, licences, the dedupe rule and every settled scenario not shipped are in + `harness/vectors/mpp-protocol/README.md`. + +## Headline + +**The corpus is not green: 117 of 992 cells diverge, 11.8%. One parse path of eight passes all 124.** +Every divergence is a verdict disagreement, not a crash; no cell produced anything but ACCEPT or REJECT. + +| parse path | scenarios | pass | **fail** | +|---|---|---|---| +| rust | 124 | 124 | **0** | +| python | 124 | 116 | **8** | +| go | 124 | 109 | **15** | +| lua | 124 | 108 | **16** | +| php | 124 | 108 | **16** | +| ruby | 124 | 106 | **18** | +| typescript, charge call site | 124 | 102 | **22** | +| typescript, session call site | 124 | 102 | **22** | +| **total** | **992** | **875** | **117** | + +**Scope.** Every scenario in the file is an `expires` verdict, so there is no filter and no skip; each +tree asserts that the count it exercised equals the count in the file. The 104 settled `full-date` and +`full-time` scenarios are not `expires` verdicts and are not in the file, and +`harness/vectors/mpp-protocol/README.md` lists each with the rule that excluded it. + +**The rust row** means rust never disagreed with the corpus about validity, not that rust is correct: +finding 8 records rust clamping the RFC's own §5.8 leap second to `23:59:59.999999999`. It was checked as +a suspected instrument defect first, and emits mechanism-specific rejection text on four mechanisms +(`the 'year' component could not be parsed`, `the 'separator' component could not be parsed`, `offset +minute was not in range`, `second was not in range`) with minority verdicts in both directions. + +## Per-tree results + +`repo-parser`: the verdict came from an SDK function whose return value distinguishes parse failure from +a valid past date. `delegated-parser`: the SDK wrapper cannot, so the runner evaluated the parse +expression the SDK delegates to. The harness contains no RFC 3339 parser written for this work. + +| tree | parse path (file:line) | verdict source | runtime | **fail of 124** | +|---|---|---|---|---| +| rust | `rust/crates/kit/src/mpp/protocol/core/challenge.rs:173-184` `PaymentChallenge::is_expired` to `time::OffsetDateTime::parse(s, &Rfc3339)` | `delegated-parser` | `cargo 1.96.1`, `time` crate `0.3.55` | **0** | +| python | `python/src/solana_pay_kit/protocols/mpp/core/types.py:28` `_parse_rfc3339` (regex `_RFC3339_RE` at `:17`, then `datetime.fromisoformat`) | `repo-parser` | `Python 3.12.13` | **8** | +| go | `go/protocols/mpp/wire/challenge.go:114` `time.Parse(time.RFC3339, …)`, called by `PaymentChallenge.IsExpired(now)` | `delegated-parser` | `go1.26.5 darwin/arm64` | **15** | +| lua | `lua/pay_kit/protocols/mpp/expires.lua:27` `M.parse_rfc3339` | `repo-parser` | `Lua 5.5.1` | **16** | +| php | `php/src/PayCore/Rfc3339Parser.php:37` `Rfc3339Parser::parse` | `repo-parser` | `PHP 8.5.9 (cli)` | **16** | +| ruby | `ruby/lib/pay_core/rfc3339_parser.rb:28` `Rfc3339Parser.parse` | `repo-parser` | `ruby 4.0.6` | **18** | +| typescript, charge | `typescript/packages/mpp/src/client/Charge.ts:402` `assertChallengeNotExpired` to `new Date(expires).getTime()` at `:404` | `delegated-parser` | `node v26.0.0` | **22** | +| typescript, session | `typescript/packages/mpp/src/server/Session.ts:356` `assertChallengeOpenNotExpired` to `Date.parse(expires)` at `:358` | `delegated-parser` | `node v26.0.0` | **22** | + +**Every row is a property of (SDK source x that runtime), not of the SDK alone.** +`rust/crates/kit/Cargo.toml:163` pins `time = "0.3"` with no lockfile, so a fresh resolve takes whatever +`0.3.x` is current; this run resolved `0.3.55`. pay-kit's Lua rockspecs target Lua 5.1 and 5.4; this run +used Lua 5.5.1. + +Python carries a second RFC 3339 grammar. It is reachable and not counted in the 992. + +| tree | second path | why it exists | +|---|---|---| +| python | `_ISO8601_RE` at `headers.py:32`, used at `headers.py:229` to gate receipt timestamps | the SDK's second RFC 3339 grammar (finding 5) | + +**Python 3.9 has no measurement.** `types.py:7` executes `from datetime import UTC` and raises +`ImportError: cannot import name 'UTC' from 'datetime'`; `datetime.UTC` is Python 3.11 and later. +Reported as unwired rather than as 124 disagreements. + +## Findings + +Nine findings, each a measurement over the shipped file unless stated otherwise. Divergence is measured, +exploitability is not. Consequence is inference, at most once per finding, in a `> **Inference:**` block. + +### 1. An offsetless date-time is host-dependent in the typescript path + +`offset_absent` (`2026-01-29T12:00:00`, corpus REJECT) is the only offsetless date-time in the file, +found by regex `^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?$` over all 124 inputs, not by name. Six +trees reject it: rust, go, python, ruby, php, lua. TypeScript accepts it and reads it in host local time: + +``` +TZ=UTC 2026-01-29T12:00:00 -> 2026-01-29T12:00:00.000Z +TZ=America/New_York 2026-01-29T12:00:00 -> 2026-01-29T17:00:00.000Z +TZ=Asia/Tokyo 2026-01-29T12:00:00 -> 2026-01-29T03:00:00.000Z +``` + +§5.6 makes the offset mandatory in `date-time` and §4.4 states why; ECMA-262 reads an offsetless form as +local. **Limit:** one vector, written from the RFC text, since neither import has an offsetless +date-time. + +> **Inference:** for any input omitting an offset, the verdict and instant this path produces are +> properties of the host as well as the input. How such an input reaches this path is not measured. + +### 2. The typescript path evaluates ECMA-262 date-time strings, not RFC 3339 + +`Charge.ts:402` `assertChallengeNotExpired` evaluates `new Date(expires).getTime()` at `:404`; +`Session.ts:356` `assertChallengeOpenNotExpired` evaluates `Date.parse(expires)` at `:358`. Both resolve +at `48ea8aa`, are the same V8 algorithm, and return byte-identical verdicts on all 124 scenarios. +**16 of the 22 divergences at each call site are over-acceptances, an input the corpus rejects that this +path accepts; the remaining 6 run the other way.** Those 6 are the leap-second `date-time`s — +`jsts_date_time_005`, `jsts_date_time_006`, `leap_second_june_month_end`, +`leap_second_offset_rolls_local_date_forward`, `rfc_5_8_example_leap_second_offset`, +`rfc_5_8_example_leap_second_z` — all corpus ACCEPT under §5.7 and all rejected here, V8 having no +representable 61st second; finding 6 reads one of them against its control. The over-acceptances follow. +Rows 1-2 are direct probes, rows 3-5 the shipped vectors `jsts_date_time_010`, `jsts_date_time_013`, +`jsts_date_time_016`. + +| input | `Date.parse` result | RFC 3339 | +|---|---|---| +| `2026-01-01` | `2026-01-01T00:00:00.000Z`, accepted | REJECT, a date-only form is not a `date-time` | +| `2026-01-01T12:00` | `2026-01-01T18:00:00.000Z`, accepted | REJECT, `time-second` is mandatory | +| `1990-02-31T15:59:59.123-08:00` | `1990-03-03T23:59:59.123Z`, rolled over | REJECT, §5.7 February maximum | +| `1990-12-31T24:00:00Z` | `1991-01-01T00:00:00.000Z`, rolled over | REJECT, §5.7 hour 00-23 | +| `06/19/1963 08:30:06 PST` | `1963-06-19T16:30:06.000Z`, accepted | REJECT, not §5.6 syntax at all | + +Five shipped vectors take the calendar-rollover path: all corpus REJECT, all accepted, `Session.ts` +matching `Charge.ts` on each. `epochMs` is read from the results file; the instant beside it is decoded. + +| vector | input | `Charge.ts` result | rolls to | +|---|---|---|---| +| `feb_30` | `2026-02-30T00:00:00Z` | ACCEPT, `epochMs=1772409600000` | `2026-03-02T00:00:00.000Z` | +| `sep_31` | `2026-09-31T00:00:00Z` | ACCEPT, `epochMs=1790812800000` | `2026-10-01T00:00:00.000Z` | +| `apr_31` | `2026-04-31T00:00:00Z` | ACCEPT, `epochMs=1777593600000` | `2026-05-01T00:00:00.000Z` | +| `non_leap_year_1900_feb_29` | `1900-02-29T00:00:00Z` | ACCEPT, `epochMs=-2203891200000` | `1900-03-01T00:00:00.000Z` | +| `hour_24` | `2026-01-29T24:00:00Z` | ACCEPT, `epochMs=1769731200000` | `2026-01-30T00:00:00.000Z` | + +**Three further divergences are bare-production forms. The third is the sharpest in the finding.** + +| vector | input | corpus | `new Date(x)` / `Date.parse(x)` resolves to | +|---|---|---|---| +| `bare_date_no_time` | `1963-06-19` | REJECT | `1963-06-19T00:00:00.000Z`, silently becomes midnight UTC | +| `bare_date_leap_day` | `2020-02-29` | REJECT | `2020-02-29T00:00:00.000Z`, same | +| `bare_time_leap_second` | `23:59:60Z` | REJECT | **`1960-01-01T23:59:00.000Z`**, read as year 1960 with the leap second truncated to `:00` | + +An `expires` of `23:59:60Z` does not fail here; it becomes an instant in 1960, which every comparison +reads as long expired. `bare_time_zulu` (`08:30:06Z`) and the other three bare-time shapes are correctly +rejected, so the defect is specific to the leap-second form. + +### 3. An empty `expires` short-circuits the go guard and returns not-expired without parsing + +`go/protocols/mpp/wire/challenge.go`, read off disk at `48ea8aa`, byte-identical to `16777d46`: + +```go +110: func (c PaymentChallenge) IsExpired(now time.Time) bool { +111: if strings.TrimSpace(c.Expires) == "" { +112: return false +113: } +114: expiresAt, err := time.Parse(time.RFC3339, c.Expires) +``` + +**The guard at `:111-113` returns `false`, not expired; `time.Parse` at `:114` is not reached.** Line 111 +tests `TrimSpace`, so the early-return class is empty or whitespace-only, and the value handed to +`time.Parse` is untrimmed, which is why a trailing-newline input passes in go. `empty_string` is corpus +REJECT: six trees reject it, go reports ACCEPT, and that one vector is the whole difference between the +go wrapper and raw `time.Parse`, which errors on `""` and agrees on every other input. The go runner +reads its verdict from `time.Parse`, so the corpus scores go's parser; the `IsExpired` early return is +reported here and not counted. + +**A semantic question, not a plain defect.** `IsExpired` is a field-level surface where `expires: ""` +means *no expiry set*, not *malformed*; the other six drive the parser, which rejects `""` as a +`date-time`. Python's suite asserts `test_empty_expires_never_expired` at the field level, so both +readings are deliberate in this repository, and **a boolean corpus carries only one.** + +**Reachability**, client path only, from untrusted input. Paths are relative to `go/protocols/mpp/`. + +| hop | file:line | what happens | +|---|---|---| +| 1 | `client/transport.go:38` | `PaymentTransport.RoundTrip`, an `http.RoundTripper` installed by `NewClient` (`:110`), on every request | +| 2 | `client/transport.go:56-60` | a remote `402` is parsed; `resp.Header` is whatever the remote sent | +| 3 | `wire/headers.go:81` | `ParseWWWAuthenticate(value)`, per header value | +| 4 | `wire/headers.go:60` | `Expires: params["expires"]`, a `map[string]string`: absent yields `""`, so absent and empty collapse | +| 5 | `wire/headers.go:68-70` | the required-field gate covers `ID`, `Realm`, `Intent`; `Expires` is required by nothing | +| 6 | `client/transport.go:65` to `:105-107` | `isSupportedChargeChallenge` filters on method and intent only; `grep -n "Expires" client/transport.go` returns nothing | +| 7 | `client/charge.go:295` | `challenge.IsExpired(time.Now())` is called | +| 8 | `wire/challenge.go:111-112` | the early return fires, returns `false`, the client proceeds to sign | + +A second entry point reaches the same guard: `ParseSessionChallenge` (`client/session.go:335`) and +`challenge_selection.go:112` feed the same unfiltered type. + +**The server path is blocked three ways, each sufficient alone.** (1) HMAC ordering: `Verify` precedes +the guard, `server/server.go:457` before `:461` and `server/session_method.go:380` before `:383`. +(2) `expires` is in the HMAC preimage, `challenge.go:105` passing `c.Expires` to `ComputeChallengeID` as +the sixth field (`:147`), so blanking it changes the ID and `Verify` fails first. (3) Both issuers +default it to five minutes, `server/server.go:375-378` and `server/session_method.go:349-352`; the Rust +issuers likewise at `charge.rs:541-542` and `:655-656`. + +**Defensive depth, not a vulnerability.** The early return is documented as deliberate two lines above +the call site (`client/charge.go:293-294`): + +```go +293 // #10: always refuse to sign an expired challenge. Challenges with no +294 // expiry are still accepted (the protocol allows omitting it). +``` + +Three confirmations that "no expiry means not expired" is convention rather than a Go defect: that +comment; Rust's `None => false` at `challenge.rs:175`; named tests in both trees (`challenge_test.go:48` +`TestIsExpiredEmptyString`, `challenge.rs:610` `challenge_not_expired_when_no_expires`). + +**The residual is the actionable part.** `NewChallengeWithSecret` (`wire/challenge.go:63-64`) is exported +public API hardcoding `expires: ""` into `NewChallengeWithSecretFull`, re-exported as +`mpp.NewChallengeWithSecret` (`core/mpp.go:68`). Its only non-test callers are the two issuers above, +which default the field, **so the safe default lives in the callers, not the constructor.** A downstream +server built on the exported constructor issues challenges HMAC-valid over an empty `expires` that reach +`server.go:461` / `session_method.go:383` and return "not expired" indefinitely. + +**A related Go / Rust divergence, recorded without taking a side.** + +| wire input | Rust, `server/charge.rs:855-857` | Go, `challenge.go:111` | +|---|---|---| +| `expires` absent | `None`, `if let` skipped, accepted | `""`, early return, accepted | +| `expires: ""` present | `Some("")`, `OffsetDateTime::parse` fails, rejected | `""`, early return, accepted | + +Rust's `Option` (`challenge.rs:17`) represents absent, present-empty and present-valid; Go's bare +`string` (`challenge.go:27`) cannot. The distinction does not survive the wire: `challenge.rs:212` folds +`None` and `Some("")` into one preimage with `expires.unwrap_or("")`, giving an identical challenge ID, +and `#[serde(skip_serializing_if = "Option::is_none")]` at `challenge.rs:16` means Rust never emits +`expires: ""`. **Which side is right is the maintainer's call.** Adding `expires` to the required-field +gate at `headers.go:68-70` makes the guard unreachable from the wire; conversely any server-side issuance +path emitting an empty `expires` raises the severity above defensive depth. The convention reading rests +on two implementations, two test suites and one source comment, not a written spec. + +### 4. php preserves six fractional digits exactly, truncates 7 to 9, and rejects 10 or more + +Issue #111 names the limit by hand: *"PHP's `%u` format only accepts 6"*. The import supplied a six-digit +vector, `jsts_date_time_001` = `1963-06-19T08:30:06.283185Z`, corpus ACCEPT. **All seven trees accept it, +and php preserves all six digits exactly.** + +| tree | verdict | parsed value | +|---|---|---| +| rust | ACCEPT | `1963-06-19 8:30:06.283185 +00:00:00` | +| go | ACCEPT | `1963-06-19T08:30:06.283185Z` | +| python | ACCEPT | `datetime(1963, 6, 19, 8, 30, 6, 283185, tzinfo=utc)` | +| ruby | ACCEPT | `1963-06-19T08:30:06.283185000Z` (nsec=283185000) | +| **php** | **ACCEPT** | **`1963-06-19T08:30:06.283185+00:00`** | +| lua | ACCEPT | `epoch=-206292594` = `1963-06-19T08:30:06Z` (lua's return type is a whole-second epoch) | +| typescript | ACCEPT | `epochMs=-206292593717` = `1963-06-19T08:30:06.283Z` | + +The limit is real and sits one digit further along. Executed against PHP 8.5.9: + +| input | result | +|---|---| +| `…06.283185Z` (6 digits) | **ACCEPT, `.283185` preserved exactly** | +| `…33.1234567Z` (7 digits) | ACCEPT, truncated to `.123456` | +| `…33.123456789Z` (9 digits) | ACCEPT, truncated to `.123456` | +| `…33.0000000000Z` (10 digits) | **REJECT (`parse()` returns null)** | +| `…33.9999999999999999Z` (16 digits) | **REJECT** | + +All five rows are shipped vectors: `jsts_date_time_001`, `secfrac_7_digits`, `secfrac_9_digits`, +`go_longfrac_10digits_0000000000`, `go_longfrac_16digits_9999999999999999`. All five are corpus ACCEPT, +so php's rejection of the last two is two of its 16 divergences. Two mechanisms, two lines: the regex at +`php/src/PayCore/Rfc3339Parser.php:26` bounds `time-secfrac` to `(?:\.(\d{1,9}))?`, producing the +rejection past 9; the truncation is `substr($frac, 0, 6)` at `:86`. On the same 9-digit input rust, go, +python and ruby preserve all nine digits (ruby `nsec=123456789`); lua and typescript truncate by return +type. **php is the only tree that loses fractional digits it claims to support.** + +### 5. The python SDK carries two RFC 3339 grammars and they disagree + +`_RFC3339_RE` at `types.py:17`, gating `_parse_rfc3339` at `types.py:28`, is the wired path. +`_ISO8601_RE` at `headers.py:32`, used at `headers.py:229` to gate receipt timestamps, is a second +grammar in the same package. Same interpreter, same run, same 124 inputs: **`_ISO8601_RE` disagrees with +the corpus on 33 of 124 where the wired `_parse_rfc3339` disagrees on 8.** + +Both directions fire. `_ISO8601_RE` is purely lexical and accepts calendar-impossible dates that +`_parse_rfc3339` rejects: `feb_30`, `month_13`, `non_leap_year_1900_feb_29`, `minute_60`, +`offset_hour_out_of_range` (`+24:00`); `_parse_rfc3339` accepts lower-case forms `_ISO8601_RE` rejects. +Two trailing-newline inputs, `jsts_date_time_027` and `trailing_newline_after_offset`, are corpus REJECT +and both match `_ISO8601_RE`, because python's `$` matches before a final newline. The 33 also include +**non-ASCII digits**: `jsts_date_time_021` = `1963-06-1৪T00:00:00Z` matches, Python's `\d` being +Unicode-aware. They gate different fields, so `_ISO8601_RE` is a second path, not part of the 992. + +> **Inference:** whether a timestamp is valid in that SDK depends on which function reaches it. Which +> function reaches a given field in production is not measured here. + +### 6. The leap second whose offset rolls the local date across midnight, and its control + +These two vectors are the sharpest discriminator in the file. + +| vector | input | corpus | rust | go | python | ruby | php | lua | typescript | +|---|---|---|---|---|---|---|---|---|---| +| `leap_second_offset_rolls_local_date_forward` | `1999-01-01T00:59:60+01:00` | **ACCEPT** | **ACCEPT** | REJECT | REJECT | ACCEPT | ACCEPT | ACCEPT | REJECT | +| `…_wrong_offset` (control) | `1999-01-01T00:59:60+02:00` | **REJECT** | **REJECT** | REJECT | REJECT | **ACCEPT** | **ACCEPT** | **ACCEPT** | REJECT | + +`00:59:60+01:00` resolves to `1998-12-31T23:59:60Z`, the final row of the RFC's Appendix D table; +`00:59:60+02:00` resolves to `1998-12-31T22:59:60Z`, not the leap-second point, and §5.7 says *"At all +other times the maximum value of time-second is '59'."* + +**Read as a pair: rust is the only tree that gets both right.** go, python and typescript reject every +`:60` and pass the control for the wrong reason, having no representable 61st second; ruby, php and lua +accept every `:60` and pass the first for the wrong reason, normalising `:60` away silently. rust rejects +the control with `time::Parse error: second was not in range`, the only implementation here that +evaluates the leap second after applying the offset. **Without the control, ruby, php and lua would have +scored a pass identical in shape to rust's and opposite in meaning.** + +### 7. A clean negative: no tree range-checks offsets against timezone data + +`time-numoffset = ("+" / "-") time-hour ":" time-minute` with `time-hour = 2DIGIT ; 00-23` and +`time-minute = 2DIGIT ; 00-59`, so `±23:59` is the widest offset §5.6 generates, well outside the ±14:00 +range any real timezone uses. `offset_max_syntactic_plus_23_59` and `offset_min_syntactic_minus_23_59` +are both corpus ACCEPT. **All seven trees accept both, with correct arithmetic.** Every tree resolved +`+23:59` to the same instant: lua `epoch=1769601660`, typescript `epochMs=1769601660000`, rust +`+23:59:00`, go `+23:59`, python `timedelta(seconds=86340)`, ruby `+23:59`, php `+23:59`; `1769601660` +decodes to `2026-01-28T12:01:00Z`, which is `2026-01-29T12:00:00` minus 23h59m. + +An adjacent case does fire. `offset_minute_out_of_range` (`+00:60`) is REJECT under §5.6, and go, python +and ruby accept it; python's returned value is the proof text, +`tzinfo=timezone(timedelta(seconds=3600))`, so `+00:60` became exactly `+01:00`. The import corroborated +this in a form this corpus had not written: `jsts_date_time_015` = `1990-12-31T10:00:00+10:60`, corpus +REJECT, accepted by the same three trees by the same mechanism. + +### 8. Trees that ACCEPT the same input and denote different instants + +The corpus verdict is ACCEPT or REJECT and nothing else, so this divergence class scores PASS everywhere. +On `rfc_5_8_example_leap_second_z` = `1990-12-31T23:59:60Z`, the RFC §5.8 worked example, corpus ACCEPT: + +| tree | verdict | instant returned | +|---|---|---| +| rust | ACCEPT | `1990-12-31 23:59:59.999999999 +00:00:00`, clamped down | +| php | ACCEPT | `1990-12-31T23:59:59.000000+00:00`, clamped down, fraction dropped | +| ruby | ACCEPT | `1991-01-01T00:00:00.000000000Z`, rolled forward into the next year | +| lua | ACCEPT | `epoch=662688000` = `1991-01-01T00:00:00Z`, rolled forward | +| go / python / typescript | REJECT | (none) | + +**Four trees accept and return two different instants, one second apart, across a year boundary, and a +boolean corpus scores all four as PASS.** On the finding 6 control vector, which the corpus rejects, the +three accepting trees split again: ruby `01:00:00+02:00`, php `00:59:59+02:00`, lua `epoch=915145200` +(`1999-01-01T01:00:00+02:00`). + +> **Inference:** for a field whose purpose is to say when a challenge stops being valid, a difference in +> the denoted instant is a difference in when it stops being valid. This corpus does not measure it. + +### 9. A space where §5.6 mandates a `T`, and a five-two split + +`2020-01-01 00:00:00Z`, imported from the upstream `date.json` suite with verdict REJECT. **rust and +typescript accept it; go, python, ruby, php and lua reject it.** + +``` +$ rust_runner probe.tsv +space_sep 1 2020-01-01 0:00:00.0 +00:00:00 +``` + +The `time` crate's `Rfc3339` well-known format accepts a space separator. §5.6's ABNF mandates `T` while +the same section's second NOTE contemplates separating full-date and full-time "by (say) a space +character", and one verdict cannot satisfy both; the upstream `date.json` case answers it sideways, +through a REJECT that judges `date` rather than `date-time`. **This vector does not ship, and the finding +is recorded on that basis:** it sits in the disputed list in `harness/vectors/mpp-protocol/README.md`, +and **how it should be verdicted is an open question, not settled here.** + +## The one vector RFC 3339 does not decide + +**`1582-10-10`**, from the upstream `date.json` suite, which asserts `valid: true` and describes it as +*"valid: date inside the Julian to Gregorian reform gap"*, a deliberate position. **It is marked +`UNRESOLVED-PROVENANCE` and does not ship in the vector file**, its absence independently verified. The +date fell inside the ten days, 5 to 14 October 1582, skipped when the Gregorian calendar replaced the +Julian one: under a proleptic Gregorian calendar it exists, under the historical calendar it never +occurred. RFC 3339 says which calendar it profiles and never says which reading applies. + +| section | what it says | why it does not decide | +|---|---|---| +| §5.6 | `full-date = date-fullyear "-" date-month "-" date-mday`, `4DIGIT`/`2DIGIT` fields | Purely syntactic. `1582-10-10` matches. The grammar cannot express "this day did not occur". | +| §5.7 | the `date-mday` maximum table: October to 31 | Constrains the maximum day number only; 10 is at most 31. Reading its silence as permission is inference, not citation. | +| §2 | "leap year" defined on the Gregorian calendar, with the /4, /100, /400 rule | Arithmetic about leap years, not calendar adoption. "The Gregorian calendar" is unqualified. | +| Appendix C | the same rule in sample C code | Says nothing about 1582. | +| §1 / Abstract | "…using the Gregorian calendar" | One unqualified noun phrase; it does not distinguish the two readings. | +| §3, Appendix D | two-digit years; leap seconds | Neither is about calendar adoption. | + +**The strongest argument for deciding it, and why it was not taken.** §2's leap-year arithmetic is +applied in this same corpus to years more than a millennium before the Gregorian calendar existed: +`0100-02-29` is REJECT and `0400-02-29` is ACCEPT, decided purely by the /100 and /400 rule. Year 0100 +*was* a Julian leap year, so `0100-02-29` was a real date and RFC 3339's rule rejects it. **RFC 3339 +therefore applies Gregorian arithmetic proleptically, and the word "proleptic" appears nowhere in it** +(`grep -niE "gregorian|julian|proleptic|calendar"` over the pinned text returns four hits, none +addressing the reform). That is an inference from the document's structure, not a statement in it. +**The suite is right that an implementation has to do something. It is not right that RFC 3339 told it +what.** + +## Limitations + +- **The boolean axis is blind to the instant divergence in finding 8.** No golden instant is asserted for + ACCEPT scenarios, so finding 8's four instants all score PASS. Same limit on php's truncation in + finding 4: it accepts 7- and 9-digit fractions the corpus accepts, truncates both to six, passes both. +- **Two trees cannot be conformance-tested through their own public surfaces.** rust's `is_expired()` + collapses parse failure and past date into one fail-closed bool and reads `OffsetDateTime::now_utc()` + internally; `server/session.rs:67-69` collapses both into `Error::ChallengeExpired`; `server/charge.rs:857` + distinguishes but is reachable only after a constant-time HMAC challenge-ID check. typescript's two + expiry checks are module-private and the package ships no RFC 3339 parser module. Hence + `delegated-parser` on both. +- **Kotlin and Swift are not measured.** Neither is on `langs` in `harness/divergence-raw.json` nor in + `harness/protocol-runners/`. pay-kit carries nine SDK trees; seven are measured here. +- **Python 3.9 has no measurement.** The SDK does not load on it. +- **No `expires` verdict is compared against a normalised instant, a timezone database, or a clock.** The + corpus asserts grammar and calendar validity per RFC 3339 and nothing else. +- **Neither import contains a leap second in a positive offset that rolls the local date across + midnight.** The only vectors covering it are the two in finding 6, written for this corpus; the + upstream `time` file covers the offset arithmetic on `full-time`, which has no date to roll. + +## Reproduce + +**Six of the seven trees exit non-zero**, so the commands are not chained with `&&`. For five that is the +correct result. **Every line states the number of +tests it must run**, because a wrong `-run` pattern or a stale path exits 0 on several of these +toolchains. + +``` +# 1. Hash gate. If it does not match, nothing below applies. From the repository root: +shasum -a 256 harness/vectors/mpp-protocol/expires.json +# 613fdb3f31faffd65db0bdd44693112e5c8c2b99655455f3549900afb19696cc exit 0 + +# 2. The seven trees. Run each; none depends on another. + +# go from the repository root; -C go because the go module root is go/. -v or no count is printed +go -C go test -v ./protocols/mpp/wire/ -run TestRFC3339ConformanceCorpus # exit 1, 15 FAIL of 124 + +# python from python/ +pytest tests/test_expires.py # exit 1, 9 FAIL of 124 + +# rust from rust/ +cargo test -p solana-pay-kit rfc3339_conformance_corpus # exit 0, 0 FAIL of 124 + +# typescript from typescript/; drives `parseRfc3339`, so needs #286 in the worktree +npx vitest run packages/mpp/src/__tests__/client-charge-validation.test.ts # exit 1, 6 FAIL of 148 + +# ruby from ruby/; needs ruby >= 3.1, see the prerequisites table +bundle exec ruby test/pay_core/expires_rfc3339_test.rb # exit 1, 18 FAIL of 124 + +# php from php/; composer install first +./vendor/bin/phpunit tests/PayCore/Rfc3339Test.php # exit 1, 16 FAIL of 124 + +# lua from lua/; the suite's runner is tests/test_helper.lua in-repo, not busted +lua -e "package.path=table.concat({'./?.lua','./?/init.lua',package.path},';'); \ + require('tests.expires_rfc3339_spec'); require('tests.test_helper').run()" +# exit 1, 1 of 8 tests fails, printing "16 of 124 vectors diverge" +``` + +The numbers those commands printed, not a prediction. +The `fail` column counts corpus vectors and equals the headline table row for row, except typescript: +the `npx vitest` line drives `parseRfc3339` under #286 and returns 6, where the headline rows are the +merged tree's 22 each. No adapter implements `expires.parse` (`harness/src/protocol/README.md:68`), so those two +are evaluated directly, from the repository root: + +``` +node -e 'const s=require("./harness/vectors/mpp-protocol/expires.json").scenarios; + for(const[n,f]of[["charge",x=>new Date(x).getTime()],["session",x=>Date.parse(x)]]) + console.log(n,s.filter(v=>!Number.isNaN(f(v.input))!==(v.tests.parse===true)).length)' +# charge 22 +# session 22 exit 0 +``` + +| tree | working directory | exit | tests run | corpus vectors driven | pass | **fail** | +|---|---|---|---|---|---|---| +| go | repo root | 1 | 125 (`-v` to see them) | 124 | 109 | **15** | +| python | `python/` | 1 | 144 | 124 | 136 | **8** | +| rust | `rust/` | **0** | 3 | 124 | 3 | **0** | +| typescript | `typescript/` | 1 | 148 | 124 | 142 | **6**, `parseRfc3339` under #286 | +| ruby | `ruby/` | 1 | 130 | 124 | 112 | **18** | +| php | `php/` | 1 | 145 | 124 | 129 | **16** | +| lua | `lua/` | 1 | 8 | 124 | 7 | **1**, reporting 16 of 124 diverging | + +**Every tree drives all 124 scenarios. There is no filter and no skip.** Six enforce it by assertion, as +in rust's `rfc3339_conformance_corpus_exercises_every_scenario` or lua's +`t.assert_true(admitted == #corpus.scenarios, ...)`. **go is the exception:** it ranges directly over the +decoded array, so its count is visible in the run rather than asserted. + +**Two invocation notes, each of which cost a wrong answer once.** `-run Expires` matches none of the 14 +test functions in `challenge_test.go` and go reports `PASS … [no tests to run]` with exit 0; the symbol +is `TestRFC3339ConformanceCorpus`. The rust filter must be `rfc3339_conformance_corpus`, not `expires`, +which selects 16 unrelated helper tests and runs none of the three conformance tests. + +### The same eight commands, as one command + +Same commands, same directories, from the repository root. **No `&&`**, and each entry runs in its own +subshell so no `cd` leaks. The go line carries `-v` because without it go prints no count. + +``` +for t in gate go python rust typescript ruby php lua; do \ + printf '\n===== %s =====\n' "$t"; \ + ( case $t in \ + gate) shasum -a 256 harness/vectors/mpp-protocol/expires.json ;; \ + go) go -C go test -v ./protocols/mpp/wire/ -run TestRFC3339ConformanceCorpus ;; \ + python) cd python; pytest tests/test_expires.py ;; \ + rust) cd rust; cargo test -p solana-pay-kit rfc3339_conformance_corpus ;; \ + typescript) cd typescript; npx vitest run packages/mpp/src/__tests__/client-charge-validation.test.ts ;; \ + ruby) cd ruby; bundle exec ruby test/pay_core/expires_rfc3339_test.rb ;; \ + php) cd php; ./vendor/bin/phpunit tests/PayCore/Rfc3339Test.php ;; \ + lua) cd lua; lua -e "package.path=table.concat({'./?.lua','./?/init.lua',package.path},';'); require('tests.expires_rfc3339_spec'); require('tests.test_helper').run()" ;; \ + esac ); \ + printf '===== %s exit %d =====\n' "$t" "$?"; \ +done +``` + +Each entry's output prints in full between the delimiters, greppable with `| grep '^====='`. Executed +from the repository root, the eight delimiters printed: + +``` +===== gate exit 0 ===== # 613fdb3f…696cc +===== go exit 1 ===== # 125 "=== RUN" (1 parent + 124 subtests), 109 PASS, 15 FAIL +===== python exit 1 ===== # 8 failed, 136 passed (144) +===== rust exit 0 ===== # running 3 tests; 3 passed, 0 failed +===== typescript exit 1 ===== # 6 failed | 142 passed (148) +===== ruby exit 1 ===== # 130 runs, 155 assertions, 18 failures, 0 errors +===== php exit 1 ===== # Tests: 145, Assertions: 153, Failures: 16 +===== lua exit 1 ===== # 7 tests passed, 0 skipped, 1 failed, "16 of 124 vectors diverge" +``` + +### Prerequisites per tree + +Every row was established by running the command above, not by reading a manifest. + +| tree | prerequisite | probe | +|---|---|---| +| go | none beyond the toolchain | `go version` gives `go1.26.5 darwin/arm64` | +| python | the SDK with dev extras (`uv sync --extra dev`, or `pip install -e ".[dev]"` in a 3.12 venv) | `python3.12 --version` gives `Python 3.12.13` | +| rust | a resolvable workspace. **It does not resolve offline**: `cargo test --offline` fails on a `solana-bpf-loader-program` version conflict via `litesvm`, pre-existing and unrelated to these vectors | `cargo --version` gives `1.96.1` | +| typescript | `pnpm install` in `typescript/` | `node --version` gives `v26.0.0` | +| ruby | `bundle install`, **and ruby >= 3.1**, see below | `ruby --version` | +| php | `composer install` in `php/` | `php --version` gives `8.5.9` | +| lua | none. The runner is `lua/tests/test_helper.lua`, in-repo; busted is not a dependency. **`lua tests/run.lua` (the whole suite) additionally needs `luasodium` from luarocks; the single-spec invocation above does not** | `lua -v` gives `Lua 5.5.1` | + +**The ruby row.** `bundle exec` cannot run under ruby 2.6.10: the gemspec requires `ed25519 ~> 1.4` +(ruby >= 3.0) and `minitest ~> 5.25` (ruby >= 3.1), so the suite will not load, and a reader on system +ruby sees `Gem::GemNotFoundException` rather than a result. The row was measured under ruby 4.0.6, and +ruby's parser returns the same 18 failures on the same 124 vectors under 2.6.10p210, so the count is not +runtime-sensitive. diff --git a/go/protocols/mpp/wire/challenge_test.go b/go/protocols/mpp/wire/challenge_test.go index 6cb98c0e6..c460a0344 100644 --- a/go/protocols/mpp/wire/challenge_test.go +++ b/go/protocols/mpp/wire/challenge_test.go @@ -1,6 +1,8 @@ package wire import ( + "encoding/json" + "os" "testing" "time" ) @@ -161,3 +163,98 @@ func TestNewPaymentCredentialRejectsUnmarshalablePayload(t *testing.T) { t.Fatal("expected marshal error") } } + +// ── Cross-SDK RFC 3339 conformance corpus (issue #111) ── +// +// Vectors live in `harness/vectors/mpp-protocol/expires.json` under the +// `expires.parse` operation. Every SDK asserts the same ACCEPT / REJECT verdict +// against the same vectors, so a divergence between two SDKs shows up as a +// failing test in exactly one of them rather than as silence. +// +// Every scenario in the file is run. There is no slice to select and no +// scenario to skip. +// +// The verdict is read from `time.Parse` with `time.RFC3339`, the parser +// `IsExpired` calls. It is not read from `IsExpired` itself, which treats an +// empty `expires` as "no expiry" by design and so cannot report a parse +// failure for "". + +const conformanceCorpusPath = "../../../../harness/vectors/mpp-protocol/expires.json" + +type rfc3339Scenario struct { + Name string `json:"name"` + Description string `json:"description"` + Input string `json:"input"` + Tests json.RawMessage `json:"tests"` +} + +type rfc3339Corpus struct { + Scenarios []rfc3339Scenario `json:"scenarios"` +} + +// wantsAccept reports whether the scenario's `tests.parse` encodes ACCEPT. +// `true` is ACCEPT; an object carrying `success: false` is REJECT. Mirrors the +// encoding used by the other vector files in the same directory. +func (s rfc3339Scenario) wantsAccept(t *testing.T) bool { + t.Helper() + var probe struct { + Parse json.RawMessage `json:"parse"` + } + if err := json.Unmarshal(s.Tests, &probe); err != nil { + t.Fatalf("%s: tests block did not decode: %v", s.Name, err) + } + var accepted bool + if err := json.Unmarshal(probe.Parse, &accepted); err == nil { + return accepted + } + var rejection struct { + Success bool `json:"success"` + } + if err := json.Unmarshal(probe.Parse, &rejection); err != nil { + t.Fatalf("%s: tests.parse was neither a bool nor an object: %v", s.Name, err) + } + return rejection.Success +} + +// loadRFC3339Vectors returns every scenario in the corpus. The zero-length +// check guards the loader: a truncated or empty read fails here rather than +// passing quietly with nothing left to run. +func loadRFC3339Vectors(t *testing.T) []rfc3339Scenario { + t.Helper() + raw, err := os.ReadFile(conformanceCorpusPath) + if err != nil { + t.Fatalf("conformance corpus unreadable at %s: %v", conformanceCorpusPath, err) + } + var corpus rfc3339Corpus + if err := json.Unmarshal(raw, &corpus); err != nil { + t.Fatalf("conformance corpus did not decode: %v", err) + } + if len(corpus.Scenarios) == 0 { + t.Fatal("conformance corpus carried zero scenarios") + } + return corpus.Scenarios +} + +func TestRFC3339ConformanceCorpus(t *testing.T) { + t.Parallel() + for _, scenario := range loadRFC3339Vectors(t) { + t.Run(scenario.Name, func(t *testing.T) { + t.Parallel() + _, err := time.Parse(time.RFC3339, scenario.Input) + accepted := err == nil + want := scenario.wantsAccept(t) + if accepted != want { + t.Fatalf("%s (%s): input %q — corpus expects %s, time.Parse reports %s", + scenario.Name, scenario.Description, scenario.Input, + conformanceVerdictName(want), conformanceVerdictName(accepted)) + } + }) + } +} + +func conformanceVerdictName(accepted bool) string { + if accepted { + return "ACCEPT" + } + return "REJECT" +} diff --git a/harness/src/protocol/README.md b/harness/src/protocol/README.md index 7f6a0c17d..9d20ee3fc 100644 --- a/harness/src/protocol/README.md +++ b/harness/src/protocol/README.md @@ -14,7 +14,7 @@ base64url encode/decode, and the challenge-id HMAC. | File | Role | |------|------| -| `vectors.ts` | Loads the vendored canonical vectors and flattens them into dispatchable `ProtocolCase`s (handles the `tests.: true \| {success:false,error_type}` shape, constructed `wire` shorthand, `adapters` allow-lists, and `maxDurationMs*` budgets). | +| `vectors.ts` | Loads the vendored canonical vectors and flattens them into dispatchable `ProtocolCase`s (handles the `tests.: true \| {success:false,error_type}` shape, constructed `wire` shorthand, `adapters` allow-lists, and `maxDurationMs*` budgets). Also loads the RFC 3339 `expires` vectors via `collectExpiresCases()`. | | `driver.ts` | Transport-agnostic case runner. `exact` compare for base64url / challenge.id; `semantic` compare for parse/format (re-parses both wires through the paired parse op, with credential request-encoding normalization) — mirrors the canonical runner's `compare_*_semantic`. Times the primary op and enforces the scenario's duration budget like `compare_duration`. | | `flow-driver.ts` | HTTP 402 FLOW orchestration: a TypeScript port of the normative mpp-tools `flow_runner.py`. Performs the initial request / 402 / credential retry / receipt exchange itself, routing only `challenge.parse` / `credential.format` / `receipt.parse` through a `ProtocolAdapter`, and compares recorded results against `harness/vectors/mpp-protocol-flows/golden-results.json` after the canonical normalization. | | `runners/typescript.ts` | TypeScript REFERENCE runner. In-process `ProtocolAdapter` over `mppx` (pay-kit's TS protocol core) + a stdin/stdout CLI speaking the canonical adapter ABI. | @@ -47,6 +47,30 @@ Operations (canonical, from `mpp-tools/conformance/operations.json`): `error_type` vocabulary: `parse_error` (`.parse`), `format_error` (`.format`), `encoding_error` (base64url), `generation_error` (`challenge.id`). +### `expires.parse` — proposed by this PR, NOT canonical + +| op | input | success result | compare | +|----|-------|----------------|---------| +| `expires.parse` | `{ expires }` | `{ valid: true }` | exact | + +RFC 3339 `expires` parse conformance (issue #111), driven by +`harness/vectors/mpp-protocol/expires.json` through +`collectExpiresCases()`. **This operation is not in upstream +`mpp-tools/conformance/operations.json`** — it is proposed here so the corpus +has an operation to hang from, and the name, the input/result shape, and the +`exact` comparison are all the maintainer's to overrule. On rejection the +`error_type` is `parse_error`, reusing the `.parse` vocabulary above rather +than introducing a new one. + +Every scenario in the file is an `expires` verdict and `collectExpiresCases()` +admits all of them. There is no slice to select and no scenario to skip. + +No adapter implements `expires.parse` yet, so these cases are deliberately +**not** in `collectProtocolCases()` — folding them in would turn the green +reference suite red. Nothing calls `collectExpiresCases()` today: each SDK's +`expires` test loads the vector JSON itself. The collector is the TypeScript +side of that contract, kept for the day an adapter implements the op. + ## Running ``` diff --git a/harness/src/protocol/vectors.ts b/harness/src/protocol/vectors.ts index d90342cff..77b4e21ba 100644 --- a/harness/src/protocol/vectors.ts +++ b/harness/src/protocol/vectors.ts @@ -15,6 +15,17 @@ export const VECTORS_DIR = join(here, "..", "..", "vectors", "mpp-protocol"); // Canonical adapter-ABI operation identifiers. These match // `conformance/operations.json` in mpp-tools verbatim. +// +// DIVERGENCE (this PR): `expires.parse` is the one member below that is NOT +// in upstream `conformance/operations.json`. It is proposed here, by this PR, +// to give the RFC 3339 `expires` corpus (issue #111) an operation to hang +// from; `operations.json` is not vendored in this repo (it lives upstream in +// tempoxyz/mpp-tools), so nothing here can be checked against it. The +// maintainer may want the operation added upstream, may want it named +// differently, or may want it kept as a pay-kit-local extension — all three +// are their call, and the name is trivially renameable (this union, the +// OPERATION_COMPARISON entry below, and `collectExpiresCases`). The rest of +// the union does still match upstream verbatim. export type ProtocolOperation = | "challenge.parse" | "challenge.format" @@ -24,7 +35,8 @@ export type ProtocolOperation = | "receipt.format" | "base64url.encode" | "base64url.decode" - | "challenge.id"; + | "challenge.id" + | "expires.parse"; // Comparison discipline per operation, taken from operations.json: // exact -> result string/object must equal the golden byte-for-byte @@ -42,6 +54,14 @@ export const OPERATION_COMPARISON: Record = { "base64url.encode": "exact", "base64url.decode": "exact", "challenge.id": "exact", + // `exact`: an `expires.parse` success result is the fixed literal + // `{ valid: true }` (see ExpiresScenario below) — there is no header + // whitespace, param order, or request encoding for `semantic` to + // neutralize. `semantic` in this file is also *implemented* via + // `reparseWith`, which needs a paired `*.format` op; `expires.parse` has + // no `expires.format` counterpart, so `semantic` is not mechanically + // available to it. Same shape as base64url / challenge.id above. + "expires.parse": "exact", }; // ── Raw vector file shapes ── @@ -132,6 +152,25 @@ export type ChallengeIdScenario = { expected: string; }; +// RFC 3339 `expires` scenario (issue #111). Same `{ name, description, tags, +// …, tests }` spine as every other scenario in this directory; the input field +// is named for its domain (`input`, a single untrusted string) the way header +// scenarios name theirs `wire` and base64url names its `decoded` / `encoded`. +// +// No `object` golden is carried for an ACCEPT: the file is deliberately a +// verdict corpus, not an instant-normalization corpus (leap seconds have no +// representable instant in most date libraries and sub-nanosecond precision +// truncates differently per language). #111 asks for ACCEPT/REJECT parity +// across SDKs and nothing more. +export type ExpiresScenario = { + name: string; + description?: string; + tags?: string[]; + // The single untrusted string under test. + input: string; + tests: TestFlags; +}; + type VectorFile = { version: string; spec_ref: string; @@ -164,6 +203,10 @@ export function loadChallengeId(): VectorFile { return load("challenge-id.json"); } +export function loadExpires(): VectorFile { + return load("expires.json"); +} + // A single dispatchable unit of work derived from a vector scenario: the // op to run, the adapter input envelope, and either the golden success // output (for a happy-path) or the expected error_type. @@ -337,3 +380,62 @@ export function collectProtocolCases(): ProtocolCase[] { return cases; } + +// Expand the RFC 3339 `expires` corpus into `expires.parse` cases (issue +// #111: assert ACCEPT and REJECT outcomes match across SDKs for each vector). +// +// Every scenario in the file is admitted. There is no slice to select: each row +// carries an `expires` verdict and nothing else does. +// +// ABI shape, PROPOSED BY THIS PR — the maintainer should overrule any of it: +// input : { expires: "" } +// (mirrors `{ header }` / `{ text }`: the field is named for what +// it holds) +// success : { valid: true } +// The corpus supplies no golden parsed value on purpose, so the +// success result carries the verdict itself rather than a +// normalized instant. Compared `exact` (see OPERATION_COMPARISON). +// failure : error_type "parse_error" +// NOT a new taxonomy: `parse_error` is the only `error_type` value +// that appears anywhere in this vectors directory (24/24 +// occurrences), it is the documented vocabulary for `.parse` ops +// in `harness/src/protocol/README.md`, and the TypeScript +// reference runner already maps any `*.parse` throw to it +// (`runners/typescript.ts`, catch block). The corpus encodes it +// directly in each REJECT scenario's `tests.parse`. +// +// Deliberately NOT folded into `collectProtocolCases()`. That list is driven +// end-to-end against the TypeScript reference adapter by +// `test/protocol-conformance.test.ts`, and no adapter implements +// `expires.parse` yet — folding these in would add one red case per vector to +// a green suite. Nothing calls this collector today: each SDK's `expires` test +// loads the vector JSON itself. The collector is the TypeScript side of that +// contract, kept for the day an adapter implements `expires.parse` — at which +// point absorbing it is a one-line spread into `cases` above. +export function collectExpiresCases(): ProtocolCase[] { + const cases: ProtocolCase[] = []; + + for (const s of loadExpires().scenarios) { + const input = { expires: s.input }; + const parseErr = expectsError(s.tests.parse); + if (parseErr) { + cases.push({ + op: "expires.parse", + scenario: s.name, + input, + expectSuccess: false, + errorType: parseErr, + }); + } else if (expectsSuccess(s.tests.parse)) { + cases.push({ + op: "expires.parse", + scenario: s.name, + input, + expectSuccess: true, + golden: { valid: true }, + }); + } + } + + return cases; +} diff --git a/harness/test/conformance.test.ts b/harness/test/conformance.test.ts index aa5e86f09..dfea820bc 100644 --- a/harness/test/conformance.test.ts +++ b/harness/test/conformance.test.ts @@ -25,6 +25,7 @@ import { } from "../src/conformance/contract-schema"; import { discoverRunners } from "../src/conformance/runners"; import { parseLanguageAllowlist } from "../src/conformance/select"; +import { collectExpiresCases, loadExpires } from "../src/protocol/vectors"; import type { ConformanceVector, RunnerResult, @@ -360,3 +361,82 @@ describe("cross-SDK conformance vectors", () => { }); } }); + +// ── RFC 3339 `expires` corpus collector (issue #111) ── +// +// `collectExpiresCases()` (harness/src/protocol/vectors.ts) is the TypeScript +// side of the cross-SDK `expires` contract. No adapter implements +// `expires.parse` yet, so its cases sit deliberately outside +// `collectProtocolCases()` — folding them into the protocol driver would add +// one red case per vector to a green suite. That also means nothing dispatches +// the collector, so its ACCEPT/REJECT mapping would regress silently. It is +// asserted directly here instead. +// +// This block spawns no runner and contributes no vector to the driver above. +// It reads the vendored corpus and checks the collector's output against it. +// Every count is derived at runtime from the scenario list, so a corpus edit +// moves the expectation with it rather than turning this red for the wrong +// reason. +describe("mpp-protocol RFC 3339 expires corpus collector", () => { + const corpus = loadExpires(); + const scenarios = corpus.scenarios; + const cases = collectExpiresCases(); + + it("emits a case for every scenario in the file", () => { + // A collector that silently degrades to emitting nothing, or that drops a + // scenario, fails here rather than reporting a smaller green suite. + expect(scenarios.length).toBeGreaterThan(0); + expect(cases).toHaveLength(scenarios.length); + expect(cases.map((testCase) => testCase.scenario).sort()).toEqual( + scenarios.map((scenario) => scenario.name).sort(), + ); + }); + + it("tags every admitted case with the expires.parse op", () => { + for (const testCase of cases) { + expect(testCase.op, testCase.scenario).toBe("expires.parse"); + } + }); + + it("maps every ACCEPT and REJECT verdict faithfully", () => { + const byScenario = new Map(cases.map((testCase) => [testCase.scenario, testCase])); + + let accepts = 0; + let rejects = 0; + for (const scenario of scenarios) { + const testCase = byScenario.get(scenario.name); + expect(testCase, `no case emitted for ${scenario.name}`).toBeDefined(); + if (!testCase) continue; + + const parse = scenario.tests.parse; + if (parse === true) { + accepts += 1; + expect(testCase.expectSuccess, scenario.name).toBe(true); + if (testCase.expectSuccess) { + expect(testCase.golden, scenario.name).toEqual({ valid: true }); + } + expect(testCase.input, scenario.name).toEqual({ expires: scenario.input }); + continue; + } + + expect( + typeof parse === "object" && parse.success === false, + `${scenario.name}: unexpected tests.parse encoding`, + ).toBe(true); + if (typeof parse !== "object") continue; + + rejects += 1; + expect(testCase.expectSuccess, scenario.name).toBe(false); + if (!testCase.expectSuccess) { + expect(testCase.errorType, scenario.name).toBe(parse.error_type); + } + expect(testCase.input, scenario.name).toEqual({ expires: scenario.input }); + } + + // Both verdicts must actually be represented: a mapping that collapsed one + // direction into the other would otherwise pass every assertion above. + expect(accepts).toBeGreaterThan(0); + expect(rejects).toBeGreaterThan(0); + expect(accepts + rejects).toBe(scenarios.length); + }); +}); diff --git a/harness/vectors/mpp-protocol/LICENSE.go-stdlib b/harness/vectors/mpp-protocol/LICENSE.go-stdlib new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/harness/vectors/mpp-protocol/LICENSE.go-stdlib @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/harness/vectors/mpp-protocol/LICENSE.json-schema-test-suite b/harness/vectors/mpp-protocol/LICENSE.json-schema-test-suite new file mode 100644 index 000000000..c28adbadd --- /dev/null +++ b/harness/vectors/mpp-protocol/LICENSE.json-schema-test-suite @@ -0,0 +1,19 @@ +Copyright (c) 2012 Julian Berman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/harness/vectors/mpp-protocol/PATENTS.go-stdlib b/harness/vectors/mpp-protocol/PATENTS.go-stdlib new file mode 100644 index 000000000..733099041 --- /dev/null +++ b/harness/vectors/mpp-protocol/PATENTS.go-stdlib @@ -0,0 +1,22 @@ +Additional IP Rights Grant (Patents) + +"This implementation" means the copyrightable works distributed by +Google as part of the Go project. + +Google hereby grants to You a perpetual, worldwide, non-exclusive, +no-charge, royalty-free, irrevocable (except as stated in this section) +patent license to make, have made, use, offer to sell, sell, import, +transfer and otherwise run, modify and propagate the contents of this +implementation of Go, where such license applies only to those patent +claims, both currently owned or controlled by Google and acquired in +the future, licensable by Google that are necessarily infringed by this +implementation of Go. This grant does not include claims that would be +infringed only as a consequence of further modification of this +implementation. If you or your agent or exclusive licensee institute or +order or agree to the institution of patent litigation against any +entity (including a cross-claim or counterclaim in a lawsuit) alleging +that this implementation of Go or any code incorporated within this +implementation of Go constitutes direct or contributory patent +infringement, or inducement of patent infringement, then any patent +rights granted to you under this License for this implementation of Go +shall terminate as of the date such litigation is filed. diff --git a/harness/vectors/mpp-protocol/README.md b/harness/vectors/mpp-protocol/README.md index 058073bd0..7a0269e10 100644 --- a/harness/vectors/mpp-protocol/README.md +++ b/harness/vectors/mpp-protocol/README.md @@ -1,5 +1,10 @@ # MPP protocol conformance vectors (canonical) +> **Two provenances live in this directory.** Every file except `expires.json` is imported +> verbatim from `tempoxyz/mpp-tools` and is described immediately below. `expires.json` is a +> separate import with its own upstreams and its own licence files — see +> [RFC 3339 `expires` corpus](#rfc-3339-expires-corpus). + These JSON vector files are **imported verbatim** from [`tempoxyz/mpp-tools`](https://github.com/tempoxyz/mpp-tools) (`conformance/vectors/`), the canonical IETF-spec conformance suite for the @@ -52,9 +57,266 @@ may carry: ## Source / attribution +Applies to every file in this directory **except** `expires.json`. + - Upstream: `tempoxyz/mpp-tools`, `conformance/vectors/` - Upstream commit: `b15fea4ee3f12da7ece735dc778ad84102af679c` (2026-06-08) - License: MIT, Copyright (c) 2026 Tempo — see `LICENSE.mpp-tools` in this directory. Do not hand-edit these files. To refresh, re-copy from the upstream repo and bump the commit reference above. + +## RFC 3339 `expires` corpus + +| File | Operation(s) | Spec reference | +|------|--------------|----------------| +| `expires.json` | `expires.parse` | RFC 3339 §5.6 | + +RFC 3339 **parse** conformance vectors for the MPP `expires` field (issue #111): 124 scenarios, 54 ACCEPT and 70 REJECT. Same +`{ version, spec_ref, description, commands, scenarios }` file shape, same `{ name, description, tags, … , tests }` +row shape and the same verdict encoding as the canonical files above: + +``` +"tests": { "parse": true } // ACCEPT +"tests": { "parse": { "success": false, "error_type": "parse_error" } } // REJECT +``` + +Every scenario in the file is an `expires` verdict and every scenario is exercised. There is no +filter to apply and no scenario a consumer should skip. No `object` golden is supplied for an +ACCEPT — deliberately: #111 is a verdict-alignment problem, not an instant-normalisation one, and +leap seconds have no representable instant in most date libraries. + +### Source / attribution (this file only) + +Three upstreams, none of them mpp-tools. + +- **RFC 3339** — "Date and Time on the Internet: Timestamps", Klyne & Newman, July 2002. + Retrieved 2026-08-07 from `https://www.rfc-editor.org/rfc/rfc3339.txt`. Copyright (C) The + Internet Society (2002); reproduction and derivative works permitted per the RFC's own notice. +- **JSON Schema Test Suite** — + [`json-schema-org/JSON-Schema-Test-Suite`](https://github.com/json-schema-org/JSON-Schema-Test-Suite), + `tests/draft2020-12/optional/format/date-time.json` and `.../date.json` and `.../time.json`. + Commit `15fe552d6cf76e29cc8165306fb6a72503fd360b` (2026-08-06). MIT, Copyright (c) 2012 Julian + Berman — see `LICENSE.json-schema-test-suite` in this directory. +- **Go standard library** — [`golang/go`](https://github.com/golang/go), + `src/time/format_test.go`, tables `parseTests`, `parseErrorTests` and + `longFractionalDigitsTests`. Commit `c19862e5f8415b4f24b189d065ed739517c548ba` (tag `go1.26.5`). + BSD-3-Clause, Copyright 2009 The Go Authors — see `LICENSE.go-stdlib` in this directory; + additional patent grant in `PATENTS.go-stdlib`. + +### Per-row provenance + +Provenance is recorded here rather than on every row, so that the vector file reads like its +neighbours. Each shipped scenario belongs to exactly one class: + +| class | shipped rows | what it means | verdict authority | +|---|---|---|---| +| `imported_verbatim` | 27 | input and verdict copied byte-for-byte from the JSON Schema Test Suite at the pinned commit; descriptions are the upstream's own and are not rewritten | JSON Schema Test Suite | +| `extracted_from_source` | 27 | input lifted from a test table in Go stdlib source at the pinned commit. `parseTests` and `longFractionalDigitsTests` rows fail the Go test on a parse error, so the table asserts Go ACCEPTS them; `parseErrorTests` rows fail the Go test when parsing succeeds, so the table asserts Go REJECTS them | Go stdlib | +| `derived_from_rfc_text` | 58 | derived from the §5.6 grammar, the §5.7 restrictions and the §5.8 worked examples, with the deciding section recorded per row | RFC 3339 | +| `hand_authored` | 4 | written against the ABNF to close a boundary the imports left open | RFC 3339 | +| authored for this file | 8 | the wrong-production rejections and the empty string, below | RFC 3339 §5.6 | + +### Dedupe rule + +Byte-exact on the `input` string. No normalisation and no case folding: two vectors differing only by `T` vs `t`, or by surrounding whitespace, are two vectors. + +Precedence when two sources carry the same input: `imported_verbatim` → `extracted_from_source` → `hand_authored` → `derived_from_rfc_text`. Tie-break: same provenance type: the earlier position in the source file wins. + +**6 duplicates dropped, 0 verdict disagreements** between sources. + +| input | kept | dropped | verdicts agree | +|---|---|---|---| +| `22:59:60Z` | `jsts_time_006` (imported_verbatim, REJECT) | `jsts_time_029` (imported_verbatim) | yes | +| `23:58:60Z` | `jsts_time_007` (imported_verbatim, REJECT) | `jsts_time_030` (imported_verbatim) | yes | +| `2020-11-28T23:55:45Z` | `jsts_date_039` (imported_verbatim, REJECT) | `jsts_time_041` (imported_verbatim) | yes | +| `1937-01-01T12:00:27.87+00:20` | `jsts_date_time_003` (imported_verbatim, ACCEPT) | `rfc_5_8_example_twenty_minute_offset` (derived_from_rfc_text) | yes | +| `1998-12-31T23:59:60Z` | `jsts_date_time_005` (imported_verbatim, ACCEPT) | `leap_second_december_month_end` (derived_from_rfc_text) | yes | +| (the empty string) | `jsts_date_047` (imported_verbatim, REJECT) | `empty_string` (derived_from_rfc_text) | yes | + +### Independent audit + +All 170 imported vectors were re-derived by hand from the text of RFC 3339 by a second author who did not +build the import. **Result: 169 AGREE, 0 DIVERGE, 1 UNRESOLVED-PROVENANCE.** Verdicts changed by the merge: 0. +The RFC text used was pinned at sha256 +`9ab2b8864a85dca73a88f49b0927bc7bc85f596926e4fd1890905777924e700a`. + +### Not shipped — and why. Nothing was deleted. + +The import considered 235 inputs and settled 228 scenarios across three RFC 3339 +productions. This file ships the 124 that are `expires` verdicts. The other 104 are listed individually +below, with the rule that excluded each one. + +#### Unresolved provenance (1) + +A vector whose verdict could not be tied to an RFC 3339 section does not ship here. It travels in the RFC 3339 `expires` conformance results at [`docs/mpp-expires-rfc3339-conformance.md`](../../../docs/mpp-expires-rfc3339-conformance.md) as a finding about the specification, which is its entire purpose. + +- **`jsts_date_070`**, input `1582-10-10` (upstream ACCEPT) — UNRESOLVED-PROVENANCE. RFC 3339 does not decide whether a date inside the 1582 Julian-to-Gregorian reform gap is valid: §5.6 is purely syntactic, §5.7 constrains only the maximum mday, and neither §2 nor Appendix C qualifies "the Gregorian calendar" as proleptic or historical. The upstream suite asserts valid: true. + +#### Disputed — RFC 3339 does not settle these (5) + +Recorded rather than quietly decided. Each is a literal whose verdict the text does not pin down; a +maintainer wanting one of them decided should say which way. + +| input | candidate verdicts | why it is not settled | +|---|---|---| +| `2026-01-29 12:00:00Z` | reject / accept | RFC 3339 §5.6 ABNF is date-time = full-date "T" full-time, which mandates a literal "T". But the second NOTE in the same section reads: "ISO 8601 defines date and time separated by 'T'. | +| `2026-03-15T23:59:60Z` | reject / accept | Second = 60 on a date that is not a June or December month end. §5.7 permits time-second = 60 "at the end of months in which a leap second occurs -- to date: June (XXXX-06-30T23:59:60Z) or December (XXXX-12-31T23:59:60Z)" and adds "At all other times the maximum value of time-second is 59". | +| `2026-06-30T23:59:60Z` | accept / reject | Second = 60 at a June month end in a year for which no leap second was announced. Positionally this is exactly the shape §5.7 names as permitted (XXXX-06-30T23:59:60Z), so a grammar-only reading ACCEPTS. | +| ` 2026-01-29T12:00:00Z ` | reject / accept | Surrounding whitespace. RFC 3339 §5.6 defines the date-time production and nothing else; it does not say whether a protocol field carrying a date-time may be padded. | +| `9999-12-31T23:59:59+00:01` and `0000-01-01T00:00:00-00:01` | accept / reject | Lexically valid date-times whose offset-adjusted UTC instant falls outside the four-digit year range (year 10000 and year -0001 respectively). §5.6 date-fullyear = 4DIGIT constrains the LEXICAL year only. | + +#### Wrong production, or a duplicate failure shape (104) + +The import covered three RFC 3339 productions because the upstream suites do: `date-time` (the one +`expires` holds), `full-date` (a bare `YYYY-MM-DD`) and `full-time` (a bare time with an offset). A +`full-date` or `full-time` verdict is a correct statement about a different production and is **not** +an `expires` verdict. Those rows are admitted here only when their `expires` verdict follows +mechanically from §5.6 `date-time = full-date "T" full-time` — that is, when the input is well +formed in its own production and therefore cannot also be a `date-time`. Seven such rows ship, one +per distinct failure shape, plus one restoration: + +| shipped as | input | the failure shape it covers | +|---|---|---| +| `bare_date_no_time` | `1963-06-19` | a well-formed bare full-date | +| `bare_date_leap_day` | `2020-02-29` | a bare full-date on a calendar edge — catches a parser that checks the calendar before the shape | +| `bare_time_zulu` | `08:30:06Z` | a well-formed bare full-time | +| `bare_time_lowercase_z` | `08:30:06z` | catches a parser keying on an upper-case `Z` | +| `bare_time_numeric_offset` | `08:30:06-08:00` | catches a parser keying on `Z` at all | +| `bare_time_secfrac` | `23:20:50.52Z` | catches a parser keying on the `.` | +| `bare_time_leap_second` | `23:59:60Z` | catches a leap-second special-case path | +| `empty_string` | `(the empty string)` | restores the `date-time` empty-string row that the dedupe rule above absorbed into `jsts_date_047` | + +The remaining rows are listed below. Every one of them still carries a correct verdict for its own +production; none is retracted and none is deleted. + +##### `verdict-would-flip` (1) + +**Excluded because re-expressing it would flip a verdict.** + +`jsts_date_039`, input `2020-11-28T23:55:45Z`, production `full-date`, verdict REJECT. That input is a fully conforming RFC 3339 +date-time. Upstream ships it in `.../format/date.json` as a negative case for the `full-date` +production — a date-time is not a bare `YYYY-MM-DD`, so REJECT is the right `full-date` answer. As an +`expires` verdict it would invert to ACCEPT. It is excluded rather than re-adjudicated. + +##### `wrong-production-shape-already-represented` (22) + +**Well formed in its own production, but its `expires` failure shape is already covered by one of the eight rows above.** The eleven month-length variants are one failure shape to an `expires` parser, not eleven. + +| name | input | production | its own verdict | +|---|---|---|---| +| `jsts_date_002` | `2020-01-31` | `full-date` | ACCEPT | +| `jsts_date_004` | `2021-02-28` | `full-date` | ACCEPT | +| `jsts_date_006` | `2020-03-31` | `full-date` | ACCEPT | +| `jsts_date_008` | `2020-04-30` | `full-date` | ACCEPT | +| `jsts_date_010` | `2020-05-31` | `full-date` | ACCEPT | +| `jsts_date_012` | `2020-06-30` | `full-date` | ACCEPT | +| `jsts_date_014` | `2020-07-31` | `full-date` | ACCEPT | +| `jsts_date_016` | `2020-08-31` | `full-date` | ACCEPT | +| `jsts_date_018` | `2020-09-30` | `full-date` | ACCEPT | +| `jsts_date_020` | `2020-10-31` | `full-date` | ACCEPT | +| `jsts_date_022` | `2020-11-30` | `full-date` | ACCEPT | +| `jsts_date_024` | `2020-12-31` | `full-date` | ACCEPT | +| `jsts_date_041` | `0400-02-29` | `full-date` | ACCEPT | +| `jsts_date_052` | `0001-01-01` | `full-date` | ACCEPT | +| `jsts_time_008` | `23:59:60+00:00` | `full-time` | ACCEPT | +| `jsts_time_011` | `01:29:60+01:30` | `full-time` | ACCEPT | +| `jsts_time_012` | `23:29:60+23:30` | `full-time` | ACCEPT | +| `jsts_time_015` | `15:59:60-08:00` | `full-time` | ACCEPT | +| `jsts_time_016` | `00:29:60-23:30` | `full-time` | ACCEPT | +| `jsts_time_020` | `08:30:06.283185Z` | `full-time` | ACCEPT | +| `jsts_time_021` | `08:30:06+00:20` | `full-time` | ACCEPT | +| `jsts_time_023` | `12:34:56-00:00` | `full-time` | ACCEPT | + +##### `wrong-production-malformed` (81) + +**Malformed within its own production.** Every parser rejects these for a reason that has nothing to do with production confusion, so they add no `expires` coverage. + +| name | input | production | its own verdict | +|---|---|---|---| +| `jsts_date_003` | `2020-01-32` | `full-date` | REJECT | +| `jsts_date_005` | `2020-02-30` | `full-date` | REJECT | +| `jsts_date_007` | `2020-03-32` | `full-date` | REJECT | +| `jsts_date_009` | `2020-04-31` | `full-date` | REJECT | +| `jsts_date_011` | `2020-05-32` | `full-date` | REJECT | +| `jsts_date_013` | `2020-06-31` | `full-date` | REJECT | +| `jsts_date_015` | `2020-07-32` | `full-date` | REJECT | +| `jsts_date_017` | `2020-08-32` | `full-date` | REJECT | +| `jsts_date_019` | `2020-09-31` | `full-date` | REJECT | +| `jsts_date_021` | `2020-10-32` | `full-date` | REJECT | +| `jsts_date_023` | `2020-11-31` | `full-date` | REJECT | +| `jsts_date_025` | `2020-12-32` | `full-date` | REJECT | +| `jsts_date_026` | `06/19/1963` | `full-date` | REJECT | +| `jsts_date_027` | `2013-350` | `full-date` | REJECT | +| `jsts_date_028` | `1998-1-20` | `full-date` | REJECT | +| `jsts_date_029` | `1998-01-1` | `full-date` | REJECT | +| `jsts_date_030` | `1998-13-01` | `full-date` | REJECT | +| `jsts_date_031` | `2021-02-29` | `full-date` | REJECT | +| `jsts_date_033` | `1963-06-1৪` | `full-date` | REJECT | +| `jsts_date_034` | `2020-0৪-01` | `full-date` | REJECT | +| `jsts_date_035` | `20230328` | `full-date` | REJECT | +| `jsts_date_036` | `2023-W01` | `full-date` | REJECT | +| `jsts_date_037` | `2023-W13-2` | `full-date` | REJECT | +| `jsts_date_038` | `2022W527` | `full-date` | REJECT | +| `jsts_date_040` | `0100-02-29` | `full-date` | REJECT | +| `jsts_date_042` | `2100-02-29` | `full-date` | REJECT | +| `jsts_date_043` | ` 2024-01-15` | `full-date` | REJECT | +| `jsts_date_044` | `2024-01-15 ` | `full-date` | REJECT | +| `jsts_date_045` | `2024-00-15` | `full-date` | REJECT | +| `jsts_date_046` | `2024-01-00` | `full-date` | REJECT | +| `jsts_date_048` | `2020 -01-01` | `full-date` | REJECT | +| `jsts_date_049` | `2020-01-01X` | `full-date` | REJECT | +| `jsts_date_050` | `2020-01-01Z` | `full-date` | REJECT | +| `jsts_date_051` | `2020-01-01 00:00:00Z` | `full-date` | REJECT | +| `jsts_date_053` | `20-01-01` | `full-date` | REJECT | +| `jsts_date_054` | `998-01-01` | `full-date` | REJECT | +| `jsts_date_055` | `12020-01-01` | `full-date` | REJECT | +| `jsts_date_056` | `+2020-01-01` | `full-date` | REJECT | +| `jsts_date_057` | `-2020-01-01` | `full-date` | REJECT | +| `jsts_date_058` | `২020-01-01` | `full-date` | REJECT | +| `jsts_date_059` | `YYYY-01-01` | `full-date` | REJECT | +| `jsts_date_060` | `2020-001-01` | `full-date` | REJECT | +| `jsts_date_061` | `2020-MM-01` | `full-date` | REJECT | +| `jsts_date_062` | `2020-01-001` | `full-date` | REJECT | +| `jsts_date_063` | `2020-01-DD` | `full-date` | REJECT | +| `jsts_date_064` | `2020:01:01` | `full-date` | REJECT | +| `jsts_date_065` | `2020.01.01` | `full-date` | REJECT | +| `jsts_date_066` | `2020 01 01` | `full-date` | REJECT | +| `jsts_date_067` | `2020-01/01` | `full-date` | REJECT | +| `jsts_date_068` | `2020--01-01` | `full-date` | REJECT | +| `jsts_date_069` | `2020-01--01` | `full-date` | REJECT | +| `jsts_date_071` | `2147483648-01-01` | `full-date` | REJECT | +| `jsts_date_072` | `2020-01-0:` | `full-date` | REJECT | +| `jsts_date_073` | `২0-01-01` | `full-date` | REJECT | +| `jsts_date_074` | `2020–01–01` | `full-date` | REJECT | +| `jsts_date_075` | `2020-01-01\x00` | `full-date` | REJECT | +| `jsts_time_002` | `008:030:006Z` | `full-time` | REJECT | +| `jsts_time_003` | `8:3:6Z` | `full-time` | REJECT | +| `jsts_time_004` | `8:0030:6Z` | `full-time` | REJECT | +| `jsts_time_006` | `22:59:60Z` | `full-time` | REJECT | +| `jsts_time_007` | `23:58:60Z` | `full-time` | REJECT | +| `jsts_time_009` | `22:59:60+00:00` | `full-time` | REJECT | +| `jsts_time_010` | `23:58:60+00:00` | `full-time` | REJECT | +| `jsts_time_013` | `23:59:60+01:00` | `full-time` | REJECT | +| `jsts_time_014` | `23:59:60+00:30` | `full-time` | REJECT | +| `jsts_time_017` | `23:59:60-01:00` | `full-time` | REJECT | +| `jsts_time_018` | `23:59:60-00:30` | `full-time` | REJECT | +| `jsts_time_024` | `08:30:06-8:000` | `full-time` | REJECT | +| `jsts_time_026` | `24:00:00Z` | `full-time` | REJECT | +| `jsts_time_027` | `00:60:00Z` | `full-time` | REJECT | +| `jsts_time_028` | `00:00:61Z` | `full-time` | REJECT | +| `jsts_time_031` | `01:02:03+24:00` | `full-time` | REJECT | +| `jsts_time_032` | `01:02:03+00:60` | `full-time` | REJECT | +| `jsts_time_033` | `01:02:03Z+00:30` | `full-time` | REJECT | +| `jsts_time_034` | `08:30:06 PST` | `full-time` | REJECT | +| `jsts_time_035` | `01:01:01,1111` | `full-time` | REJECT | +| `jsts_time_036` | `12:00:00` | `full-time` | REJECT | +| `jsts_time_037` | `12:00:00.52` | `full-time` | REJECT | +| `jsts_time_038` | `1২:00:00Z` | `full-time` | REJECT | +| `jsts_time_039` | `08:30:06#00:20` | `full-time` | REJECT | +| `jsts_time_040` | `ab:cd:ef` | `full-time` | REJECT | + +--- + +Do not hand-edit `expires.json`. To refresh, re-run the import against newer pinned commits and +bump the shas above. diff --git a/harness/vectors/mpp-protocol/expires.json b/harness/vectors/mpp-protocol/expires.json new file mode 100644 index 000000000..c15869ff7 --- /dev/null +++ b/harness/vectors/mpp-protocol/expires.json @@ -0,0 +1,1336 @@ +{ + "version": "2.0.0", + "spec_ref": "RFC 3339 §5.6", + "description": "Challenge expires timestamp parsing (RFC 3339 date-time)", + "commands": { + "parse": "parse-expires" + }, + "scenarios": [ + { + "name": "jsts_date_time_001", + "description": "a valid date-time string", + "tags": ["happy-path", "secfrac"], + "input": "1963-06-19T08:30:06.283185Z", + "tests": { + "parse": true + } + }, + { + "name": "jsts_date_time_002", + "description": "a valid date-time string without second fraction", + "tags": ["happy-path"], + "input": "1963-06-19T08:30:06Z", + "tests": { + "parse": true + } + }, + { + "name": "jsts_date_time_003", + "description": "a valid date-time string with plus offset", + "tags": ["happy-path", "secfrac", "offset"], + "input": "1937-01-01T12:00:27.87+00:20", + "tests": { + "parse": true + } + }, + { + "name": "jsts_date_time_004", + "description": "a valid date-time string with minus offset", + "tags": ["happy-path", "secfrac", "offset"], + "input": "1990-12-31T15:59:50.123-08:00", + "tests": { + "parse": true + } + }, + { + "name": "jsts_date_time_005", + "description": "a valid date-time with a leap second, UTC", + "tags": ["happy-path", "leap-second"], + "input": "1998-12-31T23:59:60Z", + "tests": { + "parse": true + } + }, + { + "name": "jsts_date_time_006", + "description": "a valid date-time with a leap second, with minus offset", + "tags": ["happy-path", "leap-second", "offset"], + "input": "1998-12-31T15:59:60.123-08:00", + "tests": { + "parse": true + } + }, + { + "name": "jsts_date_time_007", + "description": "an invalid date-time past leap second, UTC", + "tags": ["error", "leap-second"], + "input": "1998-12-31T23:59:61Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_008", + "description": "an invalid date-time with leap second on a wrong minute, UTC", + "tags": ["error", "leap-second"], + "input": "1998-12-31T23:58:60Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_009", + "description": "an invalid date-time with leap second on a wrong hour, UTC", + "tags": ["error", "leap-second"], + "input": "1998-12-31T22:59:60Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_010", + "description": "an invalid day in date-time string", + "tags": ["error", "calendar"], + "input": "1990-02-31T15:59:59.123-08:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_011", + "description": "an invalid offset in date-time string", + "tags": ["error", "offset", "boundary"], + "input": "1990-12-31T15:59:59-24:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_012", + "description": "an invalid closing Z after time-zone offset", + "tags": ["error", "offset", "trailing-text"], + "input": "1963-06-19T08:30:06.28123+01:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_013", + "description": "an invalid hour in date-time string", + "tags": ["error", "time-of-day"], + "input": "1990-12-31T24:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_014", + "description": "an invalid minute in date-time string", + "tags": ["error", "time-of-day"], + "input": "1990-12-31T15:60:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_015", + "description": "an invalid offset minute in date-time string", + "tags": ["error", "offset", "boundary"], + "input": "1990-12-31T10:00:00+10:60", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_016", + "description": "an invalid date-time string", + "tags": ["error", "structure"], + "input": "06/19/1963 08:30:06 PST", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_017", + "description": "case-insensitive T and Z", + "tags": ["happy-path", "case"], + "input": "1963-06-19t08:30:06.283185z", + "tests": { + "parse": true + } + }, + { + "name": "jsts_date_time_018", + "description": "only RFC3339 not all of ISO 8601 are valid", + "tags": ["error", "structure"], + "input": "2013-350T01:01:01", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_019", + "description": "invalid non-padded month dates", + "tags": ["error", "calendar", "structure"], + "input": "1963-6-19T08:30:06.283185Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_020", + "description": "invalid non-padded day dates", + "tags": ["error", "calendar", "structure"], + "input": "1963-06-1T08:30:06.283185Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_021", + "description": "invalid non-ASCII '৪' (a Bengali 4) in date portion", + "tags": ["error", "unicode"], + "input": "1963-06-1৪T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_022", + "description": "invalid non-ASCII '৪' (a Bengali 4) in time portion", + "tags": ["error", "unicode"], + "input": "1963-06-11T0৪:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_023", + "description": "invalid extended year", + "tags": ["error", "year-boundary"], + "input": "+11963-06-19T08:30:06.283185Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_024", + "description": "a numeric offset without minutes is invalid", + "tags": ["error", "offset"], + "input": "1985-04-12T23:20:50+01", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_025", + "description": "hour 24 is invalid even with a leap second", + "tags": ["error", "leap-second", "time-of-day"], + "input": "2016-12-31T24:59:60+01:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jsts_date_time_026", + "description": "a second fraction of fifteen nines is valid", + "tags": ["happy-path", "secfrac"], + "input": "1985-04-12T00:59:59.999999999999999Z", + "tests": { + "parse": true + } + }, + { + "name": "jsts_date_time_027", + "description": "a trailing newline is invalid", + "tags": ["error", "trailing-text", "whitespace"], + "input": "1985-04-12T23:20:50Z\n", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_parsetests_2010_02_04t21_00_57_08_00", + "description": "Timestamp with a negative offset and no fraction", + "tags": ["happy-path", "offset"], + "input": "2010-02-04T21:00:57-08:00", + "tests": { + "parse": true + } + }, + { + "name": "go_parsetests_2010_02_04t21_00_57_012345678_08_00", + "description": "Nanosecond fraction with a negative offset", + "tags": ["happy-path", "secfrac", "offset"], + "input": "2010-02-04T21:00:57.012345678-08:00", + "tests": { + "parse": true + } + }, + { + "name": "go_parseerror_2006_01_02t15_04_05z07_00", + "description": "Rejects a layout string used as a value", + "tags": ["error", "trailing-text"], + "input": "2006-01-02T15:04:05Z07:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_parseerror_2006_01_02t15_04_abc", + "description": "Rejects text where the seconds field belongs", + "tags": ["error", "structure"], + "input": "2006-01-02T15:04_abc", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_parseerror_2006_01_02t15_04_05_abc", + "description": "Rejects text where the offset belongs", + "tags": ["error", "offset"], + "input": "2006-01-02T15:04:05_abc", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_parseerror_2006_01_02t15_04_05z_abc", + "description": "Rejects trailing text after the Z designator", + "tags": ["error", "trailing-text"], + "input": "2006-01-02T15:04:05Z_abc", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_parseerror_2010_02_04t21_00_67_012345678_08_00", + "description": "Rejects a seconds value of 67", + "tags": ["error", "time-of-day"], + "input": "2010-02-04T21:00:67.012345678-08:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_parseerror_0000_01_01t00_00_0_00_00", + "description": "Rejects a fraction standing in for the seconds", + "tags": ["error", "secfrac"], + "input": "0000-01-01T00:00:.0+00:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_parseerror_double_quote_char", + "description": "Rejects a bare double-quote character", + "tags": ["error", "structure"], + "input": "\"", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_parseerror_0000_01_01t00_00_00_00_0", + "description": "Rejects a sign inside the offset minute field", + "tags": ["error", "offset"], + "input": "0000-01-01T00:00:00+00:+0", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_parseerror_0000_01_01t00_00_00_0_00", + "description": "Rejects two signs at the head of the offset", + "tags": ["error", "offset"], + "input": "0000-01-01T00:00:00+-0:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "go_longfrac_9digits_000000000", + "description": "Nine fractional digits, all zero", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.000000000Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_9digits_000000001", + "description": "Nine fractional digits, one nanosecond", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.000000001Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_9digits_100000000", + "description": "Nine fractional digits, one tenth of a second", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.100000000Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_9digits_100000001", + "description": "Nine fractional digits, tenth plus a nanosecond", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.100000001Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_9digits_999999999", + "description": "Nine fractional digits, all nines", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.999999999Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_9digits_012345678", + "description": "Nine fractional digits with a leading zero", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.012345678Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_10digits_0000000000", + "description": "Ten fractional digits, all zero", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.0000000000Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_10digits_0000000001", + "description": "Ten fractional digits below nanosecond resolution", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.0000000001Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_10digits_1000000000", + "description": "Ten fractional digits, one tenth of a second", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.1000000000Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_10digits_1000000009", + "description": "Ten fractional digits, last digit truncated away", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.1000000009Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_10digits_9999999999", + "description": "Ten fractional digits, all nines", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.9999999999Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_10digits_0123456789", + "description": "Ten fractional digits with a leading zero", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.0123456789Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_11digits_10000000000", + "description": "Eleven fractional digits, one tenth of a second", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.10000000000Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_11digits_00123456789", + "description": "Eleven fractional digits with two leading zeros", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.00123456789Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_12digits_000123456789", + "description": "Twelve fractional digits with three leading zeros", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.000123456789Z", + "tests": { + "parse": true + } + }, + { + "name": "go_longfrac_16digits_9999999999999999", + "description": "Sixteen fractional digits, all nines", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2021-09-29T16:04:33.9999999999999999Z", + "tests": { + "parse": true + } + }, + { + "name": "offset_max_syntactic_plus_23_59", + "description": "Offset +23:59, the largest the grammar generates", + "tags": ["happy-path", "offset", "boundary"], + "input": "2026-01-29T12:00:00+23:59", + "tests": { + "parse": true + } + }, + { + "name": "offset_min_syntactic_minus_23_59", + "description": "Offset -23:59, the smallest the grammar generates", + "tags": ["happy-path", "offset", "boundary"], + "input": "2026-01-29T12:00:00-23:59", + "tests": { + "parse": true + } + }, + { + "name": "leap_second_offset_rolls_local_date_forward", + "description": "Leap second whose offset rolls the local date forward", + "tags": ["happy-path", "leap-second", "date-rollover"], + "input": "1999-01-01T00:59:60+01:00", + "tests": { + "parse": true + } + }, + { + "name": "leap_second_offset_rolls_local_date_forward_wrong_offset", + "description": "Rejects a :60 second one hour off the leap second point", + "tags": ["error", "leap-second", "date-rollover"], + "input": "1999-01-01T00:59:60+02:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "rfc_5_8_example_fractional_z", + "description": "RFC 3339 §5.8 worked example, fraction in UTC", + "tags": ["happy-path", "rfc-example", "secfrac"], + "input": "1985-04-12T23:20:50.52Z", + "tests": { + "parse": true + } + }, + { + "name": "rfc_5_8_example_negative_offset", + "description": "RFC 3339 §5.8 worked example, -08:00 offset", + "tags": ["happy-path", "rfc-example", "offset"], + "input": "1996-12-19T16:39:57-08:00", + "tests": { + "parse": true + } + }, + { + "name": "rfc_5_8_example_leap_second_z", + "description": "RFC 3339 §5.8 worked example, leap second in UTC", + "tags": ["happy-path", "rfc-example", "leap-second"], + "input": "1990-12-31T23:59:60Z", + "tests": { + "parse": true + } + }, + { + "name": "rfc_5_8_example_leap_second_offset", + "description": "RFC 3339 §5.8 worked example, leap second at -08:00", + "tags": ["happy-path", "rfc-example", "leap-second"], + "input": "1990-12-31T15:59:60-08:00", + "tests": { + "parse": true + } + }, + { + "name": "leap_second_june_month_end", + "description": "Leap second at a June month end", + "tags": ["happy-path", "leap-second"], + "input": "1972-06-30T23:59:60Z", + "tests": { + "parse": true + } + }, + { + "name": "lowercase_t_separator", + "description": "Lower-case t as the date and time separator", + "tags": ["happy-path", "case"], + "input": "1985-04-12t23:20:50.52Z", + "tests": { + "parse": true + } + }, + { + "name": "lowercase_z_offset", + "description": "Lower-case z as the UTC designator", + "tags": ["happy-path", "case"], + "input": "1985-04-12T23:20:50.52z", + "tests": { + "parse": true + } + }, + { + "name": "lowercase_t_and_z", + "description": "Both the separator and the designator lower case", + "tags": ["happy-path", "case"], + "input": "1985-04-12t23:20:50.52z", + "tests": { + "parse": true + } + }, + { + "name": "secfrac_1_digit", + "description": "One fractional digit", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.0Z", + "tests": { + "parse": true + } + }, + { + "name": "secfrac_7_digits", + "description": "Seven fractional digits", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.1234567Z", + "tests": { + "parse": true + } + }, + { + "name": "secfrac_8_digits", + "description": "Eight fractional digits", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.12345678Z", + "tests": { + "parse": true + } + }, + { + "name": "secfrac_9_digits", + "description": "Nine fractional digits", + "tags": ["happy-path", "secfrac"], + "input": "2021-09-29T16:04:33.123456789Z", + "tests": { + "parse": true + } + }, + { + "name": "secfrac_point_with_no_digits", + "description": "Rejects a decimal point with no digits after it", + "tags": ["error", "secfrac"], + "input": "2021-09-29T16:04:33.Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "secfrac_comma_separator", + "description": "Rejects a comma as the fraction separator", + "tags": ["error", "secfrac", "separator"], + "input": "2021-09-29T16:04:33,5Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "offset_plus_zero", + "description": "Offset +00:00", + "tags": ["happy-path", "offset"], + "input": "2026-01-29T12:00:00+00:00", + "tests": { + "parse": true + } + }, + { + "name": "offset_minus_zero", + "description": "Offset -00:00, the unknown local offset", + "tags": ["happy-path", "offset"], + "input": "2026-01-29T12:00:00-00:00", + "tests": { + "parse": true + } + }, + { + "name": "offset_plus_fourteen", + "description": "Offset +14:00, the eastmost real timezone", + "tags": ["happy-path", "offset"], + "input": "2026-01-29T12:00:00+14:00", + "tests": { + "parse": true + } + }, + { + "name": "offset_minus_twelve", + "description": "Offset -12:00, the westmost real timezone", + "tags": ["happy-path", "offset"], + "input": "2026-01-29T12:00:00-12:00", + "tests": { + "parse": true + } + }, + { + "name": "offset_hour_out_of_range", + "description": "Rejects an offset hour of 24", + "tags": ["error", "offset", "boundary"], + "input": "2026-01-29T12:00:00+24:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "offset_minute_out_of_range", + "description": "Rejects an offset minute of 60", + "tags": ["error", "offset", "boundary"], + "input": "2026-01-29T12:00:00+00:60", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "offset_missing_colon", + "description": "Rejects an offset written without the colon", + "tags": ["error", "offset", "separator"], + "input": "2026-01-29T12:00:00+0000", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "offset_hour_only", + "description": "Rejects an offset written as the hour alone", + "tags": ["error", "offset"], + "input": "2026-01-29T12:00:00-08", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "offset_absent", + "description": "Rejects a timestamp with no offset at all", + "tags": ["error", "offset"], + "input": "2026-01-29T12:00:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "offset_alphabetic_zone", + "description": "Rejects an alphabetic zone abbreviation", + "tags": ["error", "offset"], + "input": "2026-01-29T12:00:00PST", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "year_9999", + "description": "Year 9999, the largest four-digit year", + "tags": ["happy-path", "year-boundary"], + "input": "9999-12-31T23:59:59Z", + "tests": { + "parse": true + } + }, + { + "name": "year_10000", + "description": "Rejects a five-digit year", + "tags": ["error", "year-boundary"], + "input": "10000-01-01T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "year_0001", + "description": "Year 0001, the smallest year every SDK represents", + "tags": ["happy-path", "year-boundary"], + "input": "0001-01-01T00:00:00Z", + "tests": { + "parse": true + } + }, + { + "name": "year_two_digit", + "description": "Rejects a two-digit year", + "tags": ["error", "year-boundary"], + "input": "26-01-29T12:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "year_sign_prefixed", + "description": "Rejects an ISO 8601 expanded-year sign prefix", + "tags": ["error", "year-boundary"], + "input": "+2026-01-29T12:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "feb_30", + "description": "Rejects February 30th", + "tags": ["error", "calendar"], + "input": "2026-02-30T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "sep_31", + "description": "Rejects September 31st", + "tags": ["error", "calendar"], + "input": "2026-09-31T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "leap_year_2000_feb_29", + "description": "February 29th in 2000, a leap year by the 400 rule", + "tags": ["happy-path", "calendar", "leap-year"], + "input": "2000-02-29T00:00:00Z", + "tests": { + "parse": true + } + }, + { + "name": "non_leap_year_1900_feb_29", + "description": "Rejects February 29th in 1900, not a leap year", + "tags": ["error", "calendar", "leap-year"], + "input": "1900-02-29T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "leap_year_2024_feb_29", + "description": "February 29th in 2024, an ordinary leap year", + "tags": ["happy-path", "calendar", "leap-year"], + "input": "2024-02-29T00:00:00Z", + "tests": { + "parse": true + } + }, + { + "name": "non_leap_year_2026_feb_29", + "description": "Rejects February 29th in a non-leap year", + "tags": ["error", "calendar", "leap-year"], + "input": "2026-02-29T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "apr_31", + "description": "Rejects April 31st", + "tags": ["error", "calendar"], + "input": "2026-04-31T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "jan_31", + "description": "January 31st, the longest month at its last day", + "tags": ["happy-path", "calendar"], + "input": "2026-01-31T00:00:00Z", + "tests": { + "parse": true + } + }, + { + "name": "month_00", + "description": "Rejects month 00", + "tags": ["error", "calendar"], + "input": "2026-00-15T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "month_13", + "description": "Rejects month 13", + "tags": ["error", "calendar"], + "input": "2026-13-15T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "mday_00", + "description": "Rejects day 00", + "tags": ["error", "calendar"], + "input": "2026-01-00T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "mday_32", + "description": "Rejects day 32", + "tags": ["error", "calendar"], + "input": "2026-01-32T00:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "time_00_00_00", + "description": "Midnight, every time field at its minimum", + "tags": ["happy-path", "time-of-day"], + "input": "2026-01-29T00:00:00Z", + "tests": { + "parse": true + } + }, + { + "name": "time_23_59_59", + "description": "The last non-leap second of the day", + "tags": ["happy-path", "time-of-day"], + "input": "2026-01-29T23:59:59Z", + "tests": { + "parse": true + } + }, + { + "name": "hour_24", + "description": "Rejects hour 24", + "tags": ["error", "time-of-day"], + "input": "2026-01-29T24:00:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "minute_60", + "description": "Rejects minute 60", + "tags": ["error", "time-of-day"], + "input": "2026-01-29T12:60:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "second_61", + "description": "Rejects second 61", + "tags": ["error", "time-of-day"], + "input": "2026-01-29T12:00:61Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "seconds_omitted", + "description": "Rejects a timestamp with the seconds omitted", + "tags": ["error", "time-of-day"], + "input": "2026-01-29T12:00Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "basic_format_no_separators", + "description": "Rejects ISO 8601 basic format with no separators", + "tags": ["error", "structure", "separator"], + "input": "20260129T120000Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "date_only", + "description": "Rejects a bare date with no time part", + "tags": ["error", "structure"], + "input": "2026-01-29", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "non_date_string", + "description": "Rejects arbitrary non-date text", + "tags": ["error", "structure"], + "input": "not-a-timestamp", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "trailing_garbage_after_offset", + "description": "Rejects trailing text after a complete timestamp", + "tags": ["error", "trailing-text"], + "input": "2026-01-29T12:00:00Z_abc", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "trailing_newline_after_offset", + "description": "Rejects a trailing newline after a complete timestamp", + "tags": ["error", "trailing-text", "whitespace"], + "input": "2026-01-29T12:00:00Z\n", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "trailing_duplicate_z", + "description": "Rejects a second UTC designator", + "tags": ["error", "trailing-text"], + "input": "2026-01-29T12:00:00ZZ", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "second_elided_secfrac_retained", + "description": "Rejects a fraction standing in for the seconds", + "tags": ["error", "secfrac", "time-of-day"], + "input": "2026-01-29T12:00:.0Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "offset_signed_minute", + "description": "Rejects a sign inside the offset minute field", + "tags": ["error", "offset"], + "input": "2026-01-29T12:00:00+00:+0", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "offset_double_sign", + "description": "Rejects two signs at the head of the offset", + "tags": ["error", "offset"], + "input": "2026-01-29T12:00:00+-0:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "secfrac_10_digits", + "description": "Ten fractional digits, one past nanosecond resolution", + "tags": ["happy-path", "secfrac", "precision-loss"], + "input": "2026-01-29T12:00:00.1234567890Z", + "tests": { + "parse": true + } + }, + { + "name": "secfrac_19_digits_exceeds_int64", + "description": "Nineteen fractional digits, past the int64 maximum", + "tags": ["happy-path", "secfrac", "overflow"], + "input": "2026-01-29T12:00:00.9999999999999999999Z", + "tests": { + "parse": true + } + }, + { + "name": "bare_date_no_time", + "description": "Rejects a well-formed bare date with no time part", + "tags": ["error", "wrong-production"], + "input": "1963-06-19", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "bare_date_leap_day", + "description": "Rejects a bare leap day, valid as a date but not a timestamp", + "tags": ["error", "wrong-production", "calendar"], + "input": "2020-02-29", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "bare_time_zulu", + "description": "Rejects a well-formed bare time with no date part", + "tags": ["error", "wrong-production"], + "input": "08:30:06Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "bare_time_lowercase_z", + "description": "Rejects a bare time closed with a lower-case z", + "tags": ["error", "wrong-production", "case"], + "input": "08:30:06z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "bare_time_numeric_offset", + "description": "Rejects a bare time carrying a numeric offset", + "tags": ["error", "wrong-production", "offset"], + "input": "08:30:06-08:00", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "bare_time_secfrac", + "description": "Rejects a bare time carrying a second fraction", + "tags": ["error", "wrong-production", "secfrac"], + "input": "23:20:50.52Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "bare_time_leap_second", + "description": "Rejects a bare time carrying a leap second", + "tags": ["error", "wrong-production", "leap-second"], + "input": "23:59:60Z", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + }, + { + "name": "empty_string", + "description": "Rejects the empty string", + "tags": ["error", "structure"], + "input": "", + "tests": { + "parse": { + "success": false, + "error_type": "parse_error" + } + } + } + ] +} diff --git a/lua/tests/expires_rfc3339_spec.lua b/lua/tests/expires_rfc3339_spec.lua index b4be18cc6..67f58a914 100644 --- a/lua/tests/expires_rfc3339_spec.lua +++ b/lua/tests/expires_rfc3339_spec.lua @@ -60,3 +60,75 @@ t.test('expires.is_expired returns true on unparseable input', function() local expires = require('pay_kit.protocols.mpp.expires') t.assert_equal(expires.is_expired('not-a-timestamp', 0), true) end) + +-- ── Cross-SDK RFC 3339 conformance corpus (issue #111) ── +-- +-- Vectors live in `harness/vectors/mpp-protocol/expires.json` under the +-- `expires.parse` operation. Every SDK asserts the same ACCEPT / REJECT verdict +-- against the same vectors, so a divergence between two SDKs shows up as a +-- failing test in exactly one of them rather than as silence. +-- +-- Every scenario in the file runs. There is no slice to select and no scenario +-- to skip. +-- +-- Decoding uses the SDK's own `pay_kit.util.json`, so the suite gains no new +-- dependency. The runner has no fixture-loading facility and no per-vector +-- test-registration idiom, so the corpus runs as a single aggregate test that +-- collects EVERY divergence and reports them all in one error. Failing on the +-- first mismatch would hide the rest. + +local function corpus_path() + -- `tests/run.lua` is driven from `lua/`, but the load path it sets also + -- allows a repo-root cwd. Locate the corpus from this file instead of from + -- the working directory. + local source = debug.getinfo(1, 'S').source:gsub('^@', '') + local dir = source:match('^(.*)/[^/]*$') or '.' + return dir .. '/../../harness/vectors/mpp-protocol/expires.json' +end + +t.test('expires.parse_rfc3339 matches the cross-SDK RFC 3339 corpus', function() + local expires = require('pay_kit.protocols.mpp.expires') + local json = require('pay_kit.util.json') + + local path = corpus_path() + local handle = io.open(path, 'r') + t.assert_true(handle ~= nil, 'conformance corpus unreadable at ' .. path) + local body = handle:read('*a') + handle:close() + + local corpus = json.decode(body) + local admitted = 0 + local divergences = {} + + for i = 1, #corpus.scenarios do + local scenario = corpus.scenarios[i] + admitted = admitted + 1 + -- `"tests": {"parse": true}` is ACCEPT; `{"parse": {"success": false, …}}` + -- is REJECT. Identical to the encoding the other vector files in the same + -- directory use. + local expect_accept = scenario.tests.parse == true + local ok, value = pcall(expires.parse_rfc3339, scenario.input) + -- A crash on hostile input is a result, and it is a REJECT. + local accepted = ok and value ~= nil + if accepted ~= expect_accept then + divergences[#divergences + 1] = string.format( + '%s: input %q -- corpus expects %s, parse_rfc3339 reports %s', + scenario.name, scenario.input, + expect_accept and 'ACCEPT' or 'REJECT', + accepted and 'ACCEPT' or 'REJECT' + ) + end + end + + -- Guard the loader: a truncated or empty read fails here rather than passing + -- quietly with nothing to run, and every scenario in the file is exercised. + t.assert_true(admitted > 0, 'corpus carried zero scenarios') + t.assert_true(admitted == #corpus.scenarios, 'not every corpus scenario was exercised') + + if #divergences > 0 then + error(string.format( + '%d of %d vectors diverge from the cross-SDK corpus:\n %s', + #divergences, admitted, table.concat(divergences, '\n ') + )) + end +end) diff --git a/php/tests/PayCore/Rfc3339Test.php b/php/tests/PayCore/Rfc3339Test.php index c8744cd18..e94f0a0dd 100644 --- a/php/tests/PayCore/Rfc3339Test.php +++ b/php/tests/PayCore/Rfc3339Test.php @@ -78,4 +78,78 @@ public function testRejectsInvalidRfc3339(string $input): void { $this->assertNull(Rfc3339Parser::parse($input)); } + + // ── Cross-SDK RFC 3339 conformance corpus (issue #111) ── + // + // Vectors live in `harness/vectors/mpp-protocol/expires.json` under the + // `expires.parse` operation. Every SDK asserts the same ACCEPT / REJECT + // verdict against the same vectors, so a divergence between two SDKs shows + // up as a failing test in exactly one of them rather than as silence. + + private const CORPUS_PATH = __DIR__ . '/../../../harness/vectors/mpp-protocol/expires.json'; + + /** + * Every scenario in the shared corpus. + * + * Every scenario in the file is an `expires` verdict; there is no slice to + * select and no scenario to skip. + * + * Verdict encoding, identical to the other vector files in the same + * directory: `"tests": {"parse": true}` is ACCEPT, and + * `"tests": {"parse": {"success": false, ...}}` is REJECT. + * + * @return array + */ + public static function conformanceVectors(): array + { + $raw = file_get_contents(self::CORPUS_PATH); + if ($raw === false) { + throw new \RuntimeException('conformance corpus unreadable at ' . self::CORPUS_PATH); + } + $corpus = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + + $vectors = []; + foreach ($corpus['scenarios'] as $scenario) { + $vectors[$scenario['name']] = [ + $scenario['name'], + $scenario['input'], + $scenario['tests']['parse'] === true, + $scenario['description'], + ]; + } + + return $vectors; + } + + #[DataProvider('conformanceVectors')] + public function testMatchesCrossSdkConformanceCorpus( + string $name, + string $input, + bool $expectAccept, + string $description + ): void { + $accepted = Rfc3339Parser::parse($input) !== null; + + $this->assertSame($expectAccept, $accepted, sprintf( + '%s (%s): input "%s" — corpus expects %s, Rfc3339Parser::parse reports %s', + $name, + $description, + $input, + $expectAccept ? 'ACCEPT' : 'REJECT', + $accepted ? 'ACCEPT' : 'REJECT' + )); + } + + /** + * Guard the loader so a regression in it cannot go silent: every scenario + * in the file is exercised, and a truncated or empty read fails here rather + * than passing quietly with nothing to run. + */ + public function testEveryCorpusScenarioIsExercised(): void + { + $corpus = json_decode(file_get_contents(self::CORPUS_PATH), true, 512, JSON_THROW_ON_ERROR); + + $this->assertSame(count($corpus['scenarios']), count(self::conformanceVectors())); + $this->assertGreaterThan(0, count(self::conformanceVectors())); + } } diff --git a/python/tests/test_expires.py b/python/tests/test_expires.py index 585d6e3d2..8cddc21d4 100644 --- a/python/tests/test_expires.py +++ b/python/tests/test_expires.py @@ -2,7 +2,11 @@ from __future__ import annotations +import json from datetime import UTC, datetime +from pathlib import Path + +import pytest from solana_pay_kit.protocols.mpp.core.expires import days, hours, minutes, seconds, weeks @@ -130,3 +134,89 @@ def test_invalid_month_rejected(self): # Lexically valid RFC 3339 shape, but month 13 fails the calendar # check delegated to datetime.fromisoformat. assert self._make_challenge("2099-13-01T00:00:00Z").is_expired() is True + + +# ── Cross-SDK RFC 3339 conformance corpus (issue #111) ── + +_CORPUS_PATH = ( + Path(__file__).resolve().parents[2] + / "harness" + / "vectors" + / "mpp-protocol" + / "expires.json" +) + + +def _load_vectors() -> list[tuple[str, str, bool, str]]: + """Return every scenario in the shared corpus. + + Yields ``(name, input, expect_accept, description)``. Every scenario in the + file is an ``expires`` verdict; there is no slice to select and no scenario + to skip. + + Verdict encoding, identical to the other vector files in the same + directory: ``"tests": {"parse": true}`` is ACCEPT, and + ``"tests": {"parse": {"success": false, ...}}`` is REJECT. + """ + corpus = json.loads(_CORPUS_PATH.read_text(encoding="utf-8")) + vectors: list[tuple[str, str, bool, str]] = [] + for scenario in corpus["scenarios"]: + expectation = scenario["tests"]["parse"] + expect_accept = expectation is True + vectors.append( + (scenario["name"], scenario["input"], expect_accept, scenario["description"]) + ) + return vectors + + +_VECTORS = _load_vectors() + + +class TestRFC3339ConformanceCorpus: + """Every SDK asserts the same ACCEPT/REJECT verdict on the same vectors. + + A divergence between two SDKs then shows up as a failing test in exactly + one of them rather than as silence. + + This class covers ``_parse_rfc3339`` in + ``solana_pay_kit.protocols.mpp.core.types`` — the grammar + ``PaymentChallenge.is_expired`` delegates to, and therefore the one an + ``expires`` field is actually checked against. It raises ``ValueError`` on + a reject and returns a ``datetime`` on an accept, so the verdict is + unambiguous. + + Note for reviewers: this SDK carries a *second* RFC 3339 grammar, + ``_ISO8601_RE`` in ``solana_pay_kit.protocols.mpp.core.headers``, which + gates receipt timestamps. It is a different regex and it is not covered + here. Wiring the same corpus to that surface is a separate change. + """ + + @pytest.mark.parametrize( + ("name", "value", "expect_accept", "description"), + _VECTORS, + ids=[vector[0] for vector in _VECTORS], + ) + def test_vector(self, name: str, value: str, expect_accept: bool, description: str): + from solana_pay_kit.protocols.mpp.core.types import _parse_rfc3339 + + try: + _parse_rfc3339(value) + accepted = True + except ValueError: + accepted = False + + assert accepted is expect_accept, ( + f"{name} ({description}): input {value!r} — " + f"corpus expects {'ACCEPT' if expect_accept else 'REJECT'}, " + f"_parse_rfc3339 reports {'ACCEPT' if accepted else 'REJECT'}" + ) + + def test_every_corpus_scenario_is_exercised(self): + """Guard the loader: a regression in it must not go silent. + + A truncated or empty read fails here rather than passing quietly with + nothing left to run. + """ + corpus = json.loads(_CORPUS_PATH.read_text(encoding="utf-8")) + assert len(_VECTORS) == len(corpus["scenarios"]) + assert len(_VECTORS) > 0 diff --git a/ruby/test/pay_core/expires_rfc3339_test.rb b/ruby/test/pay_core/expires_rfc3339_test.rb index e15d74ddb..db39914cd 100644 --- a/ruby/test/pay_core/expires_rfc3339_test.rb +++ b/ruby/test/pay_core/expires_rfc3339_test.rb @@ -5,6 +5,7 @@ # (canonical JSON) and RFC 3339 (expires) live in dedicated files. # Battle-tested vector imports are tracked separately (see follow-up # issue referenced on the same PR thread). +require "json" require_relative "../test_helper" class ExpiresRfc3339Test < Minitest::Test @@ -82,3 +83,56 @@ def test_expires_strict_rfc3339_branches assert c7.expired? end end + +# ── Cross-SDK RFC 3339 conformance corpus (issue #111) ── +# +# Vectors live in `harness/vectors/mpp-protocol/expires.json` under the +# `expires.parse` operation. Every SDK asserts the same ACCEPT / REJECT verdict +# against the same vectors, so a divergence between two SDKs shows up as a +# failing test in exactly one of them rather than as silence. +# +# Every scenario in the file runs. There is no slice to select and no scenario +# to skip. +# +# The verdict comes straight out of `PayCore::Rfc3339Parser.parse`, which +# returns a `Time` on success and `nil` on any parse failure. That is the +# parser `Challenge#expired?` delegates to, so it is the grammar an `expires` +# field is actually checked against. +class ExpiresRfc3339CorpusTest < Minitest::Test + CORPUS_PATH = File.expand_path( + "../../../harness/vectors/mpp-protocol/expires.json", __dir__ + ) + + def self.vectors + JSON.parse(File.read(CORPUS_PATH)).fetch("scenarios") + end + + VECTORS = vectors.freeze + + # Guard the loader so a regression in it cannot go silent: every scenario in + # the file is exercised, and a truncated or empty read fails here rather than + # passing quietly with nothing to run. + def test_every_corpus_scenario_is_exercised + all = JSON.parse(File.read(CORPUS_PATH)).fetch("scenarios") + assert_equal all.length, VECTORS.length + assert_operator VECTORS.length, :>, 0 + end + + VECTORS.each do |scenario| + # `"tests": {"parse": true}` is ACCEPT; `{"parse": {"success": false, …}}` + # is REJECT. Identical to the encoding the other vector files in the same + # directory use. + expect_accept = scenario.dig("tests", "parse") == true + + define_method(:"test_corpus_#{scenario.fetch("name")}") do + parsed = ::PayCore::Rfc3339Parser.parse(scenario.fetch("input")) + accepted = !parsed.nil? + assert_equal expect_accept, accepted, + format( + "%s (%s): input %p — corpus expects %s, Rfc3339Parser.parse reports %s", + scenario.fetch("name"), scenario.fetch("description"), scenario.fetch("input"), + expect_accept ? "ACCEPT" : "REJECT", accepted ? "ACCEPT" : "REJECT" + ) + end + end +end diff --git a/rust/crates/kit/src/mpp/protocol/core/challenge.rs b/rust/crates/kit/src/mpp/protocol/core/challenge.rs index 787eb7f57..2e630b49b 100644 --- a/rust/crates/kit/src/mpp/protocol/core/challenge.rs +++ b/rust/crates/kit/src/mpp/protocol/core/challenge.rs @@ -887,4 +887,140 @@ mod tests { // A bare Receipt round-trips through ReceiptKind::Charge. assert!(matches!(parsed, ReceiptKind::Charge(_))); } + + // ── Cross-SDK RFC 3339 conformance corpus (issue #111) ── + // + // Vectors live in `harness/vectors/mpp-protocol/expires.json` under the + // `expires.parse` operation. Every SDK asserts the same ACCEPT / REJECT + // verdict against the same vectors, so a divergence between two SDKs shows + // up as a failing test in exactly one of them rather than as silence. + // + // Every scenario in the file runs. There is no slice to select and no + // scenario to skip. + // + // WHY THE VERDICT IS READ OFF `OffsetDateTime::parse` AND NOT OFF + // `is_expired`. `is_expired` (this file, `PaymentChallenge::is_expired`) + // collapses the outcome into a fail-closed `bool` and reads + // `OffsetDateTime::now_utc()` internally, with no parameter to inject a + // reference instant — unlike go's `IsExpired(now)`. It therefore cannot + // distinguish "parsed, and in the past" from "did not parse", and most of + // the corpus is in the past. So the ACCEPT/REJECT axis is asserted against + // the exact expression `is_expired` delegates to. + // + // The fail-closed coupling to the shipped surface is asserted separately + // and unambiguously: every REJECT vector must make `is_expired()` true. + // That direction has no ambiguity, because a parse failure is the only way + // a REJECT vector can reach the comparison. + + const CONFORMANCE_CORPUS: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../harness/vectors/mpp-protocol/expires.json" + ); + + /// `(name, input, expect_accept, description)` for every corpus scenario. + fn conformance_vectors() -> Vec<(String, String, bool, String)> { + let raw = std::fs::read_to_string(CONFORMANCE_CORPUS).unwrap_or_else(|e| { + panic!("conformance corpus unreadable at {CONFORMANCE_CORPUS}: {e}") + }); + let corpus: serde_json::Value = + serde_json::from_str(&raw).expect("conformance corpus must be valid JSON"); + + corpus["scenarios"] + .as_array() + .expect("corpus must carry a `scenarios` array") + .iter() + .map(|scenario| { + // `"tests": {"parse": true}` is ACCEPT; `{"parse": {"success": + // false, …}}` is REJECT. Identical to the encoding the other + // vector files in the same directory use. + let expect_accept = scenario["tests"]["parse"] == serde_json::Value::Bool(true); + ( + scenario["name"].as_str().unwrap_or_default().to_string(), + scenario["input"].as_str().unwrap_or_default().to_string(), + expect_accept, + scenario["description"] + .as_str() + .unwrap_or_default() + .to_string(), + ) + }) + .collect() + } + + #[test] + fn rfc3339_conformance_corpus() { + let vectors = conformance_vectors(); + assert!( + !vectors.is_empty(), + "conformance corpus carried zero scenarios" + ); + + let mut divergences = Vec::new(); + for (name, input, expect_accept, description) in &vectors { + let accepted = + time::OffsetDateTime::parse(input, &time::format_description::well_known::Rfc3339) + .is_ok(); + if accepted != *expect_accept { + divergences.push(format!( + "{name} ({description}): input {input:?} — corpus expects {}, \ + OffsetDateTime::parse reports {}", + if *expect_accept { "ACCEPT" } else { "REJECT" }, + if accepted { "ACCEPT" } else { "REJECT" }, + )); + } + } + + assert!( + divergences.is_empty(), + "{} of {} vectors diverge from the cross-SDK corpus:\n {}", + divergences.len(), + vectors.len(), + divergences.join("\n "), + ); + } + + #[test] + fn rfc3339_conformance_corpus_rejects_fail_closed() { + // Ties the corpus to the shipped surface. A REJECT vector must make + // `is_expired()` true; a parse failure is the only route to that + // outcome for an unparseable value, so this direction is unambiguous. + let vectors = conformance_vectors(); + let rejects: Vec<_> = vectors.iter().filter(|vector| !vector.2).collect(); + assert!(!rejects.is_empty(), "corpus admitted zero REJECT vectors"); + + let mut leaked = Vec::new(); + for (name, input, _, description) in rejects { + let challenge = PaymentChallenge::new( + "id", + "realm", + "solana", + "charge", + Base64UrlJson::from_value(&serde_json::json!({})).unwrap(), + ) + .with_expires(input.clone()); + if !challenge.is_expired() { + leaked.push(format!("{name} ({description}): input {input:?}")); + } + } + + assert!( + leaked.is_empty(), + "{} REJECT vectors did not fail closed through is_expired():\n {}", + leaked.len(), + leaked.join("\n "), + ); + } + + /// Guard the loader so a regression in it cannot go silent: every scenario + /// in the file is exercised, and a truncated or empty read fails here + /// rather than passing quietly with nothing left to run. + #[test] + fn rfc3339_conformance_corpus_exercises_every_scenario() { + let raw = std::fs::read_to_string(CONFORMANCE_CORPUS).expect("corpus unreadable"); + let corpus: serde_json::Value = serde_json::from_str(&raw).expect("corpus must be JSON"); + let total = corpus["scenarios"].as_array().unwrap().len(); + let admitted = conformance_vectors().len(); + assert!(total > 0, "corpus carried zero scenarios"); + assert_eq!(admitted, total, "not every corpus scenario was exercised"); + } } diff --git a/typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts b/typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts index a5b2962ce..f5d589666 100644 --- a/typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts +++ b/typescript/packages/mpp/src/__tests__/client-charge-validation.test.ts @@ -440,3 +440,41 @@ test('#10 createCredential rejects a network that does not match expectedNetwork }); await expect(method.createCredential({ challenge })).rejects.toThrow(/does not match the expected network/); }); + +// Cross-SDK RFC 3339 conformance corpus (issue #111). +// Vectors: harness/vectors/mpp-protocol/expires.json, operation `expires.parse`. + +import { readFileSync } from 'node:fs'; + +import { parseRfc3339 } from '../shared/rfc3339.js'; + +interface ConformanceScenario { + name: string; + description: string; + input: string; + tests: { parse: true | { success: boolean } }; +} + +const CORPUS_URL = new URL('../../../../../harness/vectors/mpp-protocol/expires.json', import.meta.url); + +const corpus = JSON.parse(readFileSync(CORPUS_URL, 'utf8')) as { scenarios: ConformanceScenario[] }; +const vectors = corpus.scenarios; + +const expectsAccept = (scenario: ConformanceScenario) => scenario.tests.parse === true; + +test('every RFC 3339 corpus scenario is exercised', () => { + expect(vectors.length).toBe(corpus.scenarios.length); + expect(vectors.length).toBeGreaterThan(0); +}); + +for (const scenario of vectors) { + test(`RFC 3339 corpus / ${scenario.name}`, () => { + const accepted = !Number.isNaN(parseRfc3339(scenario.input)); + expect( + accepted, + `${scenario.name} (${scenario.description}): input ${JSON.stringify(scenario.input)} — ` + + `corpus expects ${expectsAccept(scenario) ? 'ACCEPT' : 'REJECT'}, ` + + `parseRfc3339(...) reports ${accepted ? 'ACCEPT' : 'REJECT'}`, + ).toBe(expectsAccept(scenario)); + }); +}