diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 2db15ca..294fbd7 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -902,6 +902,16 @@ "verification": "test", "safety_goal": "SG-006", "asil": "ASIL-B" + }, + { + "id": "REQ-RELAY-096", + "title": "Vector distribution manifest (relay-vectors/1)", + "text": "spec/vectors/vectors_manifest.json (spec \u00a715.8) MUST pin the canonical golden vector distribution: a vectors_version bound to spec/version.json, and a SHA-256 (lowercase hex) per file under spec/vectors/ (including spec/vectors/errors/). VectorNames() MUST exclude the manifest itself from the vector set it enumerates \u2014 it is metadata about the distribution, not a vector. VerifyVectorManifest() MUST recompute every embedded vector's SHA-256 and report a finding for any vector present but unlisted, listed but absent, or present-and-listed with a mismatched hash (spec \u00a717 Requirement 16).", + "category": "functional", + "criticality": "medium", + "verification": "test", + "safety_goal": "SG-006", + "asil": "ASIL-B" } ] } diff --git a/cmd/relay/jsonschema_test.go b/cmd/relay/jsonschema_test.go index a690d6d..61c3551 100644 --- a/cmd/relay/jsonschema_test.go +++ b/cmd/relay/jsonschema_test.go @@ -136,6 +136,11 @@ func TestGoldenVectorsConformToSchemas(t *testing.T) { } for _, p := range paths { + // spec/vectors/vectors_manifest.json (§15.8) pins the vector + // distribution; it is metadata about the set, not a vector itself. + if filepath.Base(p) == "vectors_manifest.json" { + continue + } p := p t.Run(filepath.Base(p), func(t *testing.T) { data, err := os.ReadFile(p) diff --git a/cmd/relay/main.go b/cmd/relay/main.go index 13c2c0d..9de88e8 100644 --- a/cmd/relay/main.go +++ b/cmd/relay/main.go @@ -18,7 +18,7 @@ import ( relay "github.com/SoundMatt/RELAY/v2" ) -const toolVersion = "2.3.0" +const toolVersion = "2.4.0" func main() { if err := run(os.Stdout, os.Stderr, os.Args[1:]); err != nil { diff --git a/spec/CHANGELOG.md b/spec/CHANGELOG.md index 53650b6..48f0d94 100644 --- a/spec/CHANGELOG.md +++ b/spec/CHANGELOG.md @@ -1,5 +1,42 @@ # RELAY Spec Changelog +## v2.4 — 2026-08-21 (MINOR — new §17 conformance requirement) + +- **New §17 Requirement 16 — Vector manifest.** The canonical + `spec/vectors/` distribution (new §15.8) is pinned by + `spec/vectors/vectors_manifest.json`. A conformant implementation that + embeds a local copy of these vectors MUST embed the exact pinned set, + and MUST have a CI step that fails when its embedded copy's SHA-256 + diverges from the published manifest for the `vectors_version` it + targets. Same MINOR-worthy precedent as Requirements 13, 14, and 15. +- **New §15.8 — Vector distribution.** Defines the `relay-vectors/1` + manifest shape (`kind`, `manifest_version`, `vectors_version`, + `vectors[]` — each a `{name, sha256}` pair). New embedded schema + `spec/schemas/vectors-manifest.json`. Explicitly scoped to + *distribution* of the existing envelope-level `relay.Message` fixtures + only: per §1.1/§17.1, wire format remains out of scope, and this + manifest MUST NOT be extended with byte-for-byte wire-format vectors. + (NEW-SPEC-1's originating issue proposed extending the vector set + itself to wire-format bug classes; that part is explicitly declined as + contradicting RELAY's own pre-existing scope boundary, established in + REL-SPEC-2/§17.1 — only the manifest/pinning/hashing mechanism ships + here.) +- **New §20.1 CI gate**: recompute the SHA-256 of every embedded golden + vector and fail the job on any divergence from + `spec/vectors/vectors_manifest.json` for the `vectors_version` it + targets. +- **Reference implementation**: `spec/vectors/vectors_manifest.json` + (16 vectors, hashes independently verified via `sha256sum`), + `VectorsManifest()`/`ParsedVectorsManifest()`/`VerifyVectorManifest()` in + this repo's own `vectors.go`. `VectorNames()` now excludes the manifest + file itself from the vector set it enumerates (it is metadata about the + distribution, not a vector) — this also required a matching fix in two + pre-existing test files (`spec_vectors_test.go`, + `cmd/relay/jsonschema_test.go`) that globbed `spec/vectors/*.json` + directly and would otherwise have tried to parse the manifest as a + golden vector. New `REQ-RELAY-096`. `SpecVersion` bumped to `2.4`. + Closes [NEW-SPEC-2]. + ## v2.3 — 2026-08-21 (MINOR — new §17 conformance requirement) - **New §17 Requirement 15 — Conformance manifest.** The implementation diff --git a/spec/relay-spec.md b/spec/relay-spec.md index d9831c0..34f96f7 100644 --- a/spec/relay-spec.md +++ b/spec/relay-spec.md @@ -1,4 +1,4 @@ -# RELAY Specification — v2.3 +# RELAY Specification — v2.4 **Real-time Embedded Link Abstraction Yoke** @@ -1182,7 +1182,7 @@ failure rather than a skip. Exit: `0` all equivalent, `1` any mismatch/error, "protocol": "CAN", "protocol_int": 1, "version": "1.2.3", - "spec_version": "2.3", + "spec_version": "2.4", "language": "go", "runtime": "go1.25.0", "commit": "a1b2c3d4" @@ -1207,7 +1207,7 @@ conformance failure, but §17.2's conformance manifest cannot populate its own "protocol": "CAN", "protocol_int": 1, "version": "1.2.3", - "spec_version": "2.3", + "spec_version": "2.4", "commands": ["version", "capabilities", "status", "connect", "send", "subscribe"], "transports": ["socketcan", "virtual"], "features": ["fd", "isotp", "j1939"], @@ -1278,7 +1278,7 @@ $ go-can version --format json "protocol": "CAN", "protocol_int": 1, "version": "1.2.3", - "spec_version": "2.3", + "spec_version": "2.4", "language": "go", "runtime": "go1.25.0" } @@ -1290,7 +1290,7 @@ $ go-can capabilities "protocol": "CAN", "protocol_int": 1, "version": "1.2.3", - "spec_version": "2.3", + "spec_version": "2.4", "commands": ["version", "capabilities", "status", "connect", "send", "subscribe"], "transports": ["socketcan", "virtual"], "features": ["fd", "isotp", "j1939"], @@ -1366,11 +1366,11 @@ LABEL org.opencontainers.image.licenses="MPL-2.0" LABEL io.relay.tool="" LABEL io.relay.language="go|cpp|rust|c" LABEL io.relay.binary="" -LABEL io.relay.spec-version="2.3" +LABEL io.relay.spec-version="2.4" ``` The `io.relay.spec-version` label MUST always match the value of `SpecVersion` -exported by the package (§17.12 / §19.4). The `"2.3"` shown above is an example; +exported by the package (§17.12 / §19.4). The `"2.4"` shown above is an example; update it on each spec minor release. The project directory is mounted at `/project` by convention: @@ -2123,6 +2123,54 @@ The conversion MUST be lossless (§15.7): every SOME/IP header field is preserve `FromMessage`: parse `msg.ID` as `"serviceID/methodID"` decimal pair; if malformed return `ErrMalformedMessage`. +### 15.8 Vector distribution + +The canonical golden vectors (§15.7, `spec/vectors/*.json`) are a single +source of truth: every implementation that participates in `relay interop` +or Requirement 9's envelope-conversion verification embeds an identical +copy, not a private variant. `spec/vectors/vectors_manifest.json` pins that +distribution: + +```json +{ + "kind": "relay-vectors-manifest", + "manifest_version": "relay-vectors/1", + "vectors_version": "2.4", + "vectors": [ + {"name": "can-standard-frame", "sha256": "816b402d..."}, + {"name": "errors/lin-diagnostic-wrong-checksum", "sha256": "36666ad1..."} + ] +} +``` + +`vectors_version` is bound to the RELAY spec version (§19.4) that last +changed the vector set — not necessarily every spec release, since most +releases don't touch `spec/vectors/`. `vectors` MUST list every file under +`spec/vectors/` (including `spec/vectors/errors/`), by name relative to +that directory with the `.json` suffix stripped, each paired with the +SHA-256 (lowercase hex) of its raw file bytes. + +Per Requirement 16 (§17), a conformant implementation that embeds a local +copy of these vectors (as RELAY's own reference tooling does, via +`//go:embed` in `vectors.go`) MUST embed the *exact* pinned set — +byte-for-byte — and MUST have a CI step that recomputes each embedded +vector's SHA-256 and fails the build on any divergence from +`vectors_manifest.json` for the `vectors_version` it targets. This closes +the same class of drift Requirement 15's conformance manifest closes for +§17 itself: a golden vector silently diverging from the canonical set (an +accidental local edit, a stale copy after an upstream fix) would otherwise +pass every existing check, since neither `relay conform` nor `relay +interop` currently distinguishes "this implementation's copy of a vector" +from "the canonical vector." + +`spec/vectors/*.json` content itself is unaffected: this section pins +*distribution*, not envelope semantics (already governed by §15.7). Wire +format remains explicitly out of scope per §1.1/§17.1: this manifest MUST +NOT be extended with byte-for-byte wire-format vectors, only the existing +envelope-level `relay.Message` fixtures. §17.1's SHOULD for +implementation-owned wire-format regression vectors is unaffected by this +section and remains a separate, unpinned concern. + --- ## 16. Per-Protocol Defaults @@ -2162,6 +2210,7 @@ An implementation is **RELAY-conformant** if and only if: 13. **Capabilities generation.** The capabilities document's `commands`, `transports`, `features`, `interfaces`, and `optional_interfaces` fields are generated from the same source-of-truth that gates compilation, per §12.2, not hand-maintained independently of it. 14. **Spec-version binding.** The `spec_version` value the CLI prints (§12.1, §12.2) MUST NOT be a hand-copied literal kept in sync by memory alone. A Go implementation that depends on `github.com/SoundMatt/RELAY` MUST bind its declared value directly to `relay.SpecVersion` (e.g. `const SpecVersion = relay.SpecVersion`), so a dependency bump alone keeps it current. An implementation in any other language, or a Go implementation that does not depend on the RELAY module, MUST have a CI step that fails the build when its declared `spec_version` diverges from the authoritative `spec/version.json` (§19.4) at the RELAY revision it targets. 15. **Conformance manifest.** The implementation MUST commit a `relay-conform/1` manifest (§17.2), generated by `relay conform --manifest` against its own built binary; CI MUST regenerate the manifest on every change and fail the build on any diff from the committed copy, or on any requirement entry whose `status` is `FAIL`. +16. **Vector manifest.** The canonical `spec/vectors/` distribution (§15.8) is pinned by `spec/vectors/vectors_manifest.json`. A conformant implementation that embeds a local copy of these vectors MUST embed the exact pinned set, and MUST have a CI step that fails when its embedded copy's SHA-256 diverges from the published manifest for the `vectors_version` it targets. `relay conform ` is a **black-box CLI tool**: it can only observe what the built binary's `version`/`capabilities`/`status` commands print, not the @@ -2205,7 +2254,14 @@ manifest) is split down the middle: `relay conform --manifest` itself conform`'s own reach, unlike Requirements 2–5/8–11/13/14 above — but whether CI actually regenerates it on every change and diffs it against the committed copy is, like Requirement 14, a CI-process fact `relay -conform` cannot observe from outside the binary it's given. +conform` cannot observe from outside the binary it's given. Requirement 16 +(vector manifest) is the same category as Requirement 14, not Requirement +15: unlike the conformance manifest, `relay conform` has no CLI surface +that exposes an implementation's embedded vector bytes at all, so it +cannot even partially generate this check — whether an implementation's +embedded vector copy matches the published manifest is entirely a +CI-process fact about the implementation's own build, verified by the +implementation's own CI instead. **Requirement-to-verifier lookup table**, collecting the narrative above into one place: @@ -2227,6 +2283,7 @@ into one place: | 13 | Capabilities generation | Implementation's own build process and test suite | Not observable through the CLI | | 14 | Spec-version binding | Implementation's own CI | Not observable through the CLI | | 15 | Conformance manifest | `relay conform --manifest` (content); implementation's own CI (regenerate + diff) | Split — manifest content generated by `relay conform`; CI enforcement not observable through the CLI | +| 16 | Vector manifest | Implementation's own CI | Not observable through the CLI | ### 17.1 Wire-format regression coverage (recommended) @@ -2280,7 +2337,7 @@ diffable artifact. "manifest_version": "relay-conform/1", "tool": "go-can", "binary_version": "1.2.3", - "spec_version": "2.3", + "spec_version": "2.4", "git_sha": "a1b2c3d4", "capabilities_sha256": "e3b0c44298fc1c14...", "requirements": [ @@ -2299,7 +2356,7 @@ SHA-256 of the raw `capabilities` document bytes as returned by the binary, letting a diff catch any change to the capabilities document — mechanically regenerated or not — without re-parsing it. -`requirements` MUST contain exactly one entry per §17 requirement (1–15, +`requirements` MUST contain exactly one entry per §17 requirement (1–16, extended as this document's own requirement list grows). Each entry's `status` is one of: @@ -3029,11 +3086,11 @@ clarifications and fixes in PATCH releases. `spec/version.json` is authoritative. The spec document title is informational. -Current version: **v2.3** +Current version: **v2.4** -**Go:** `const SpecVersion = "2.3"` (update in implementations targeting v2.3) -**C++:** `constexpr std::string_view kRelaySpecVersion = "2.3";` -**Rust:** `pub const RELAY_SPEC_VERSION: &str = "2.3";` +**Go:** `const SpecVersion = "2.4"` (update in implementations targeting v2.4) +**C++:** `constexpr std::string_view kRelaySpecVersion = "2.4";` +**Rust:** `pub const RELAY_SPEC_VERSION: &str = "2.4";` --- @@ -3064,6 +3121,10 @@ every pull request, run and gate on: `relay-conform/1` manifest with `relay conform --manifest` against the built CLI and fail the job on any diff from the committed copy, or on any requirement entry whose `status` is `FAIL`. +5. **Vector manifest** (§15.8, Requirement 16) — recompute the SHA-256 of + every embedded golden vector and fail the job on any divergence from + `spec/vectors/vectors_manifest.json` for the `vectors_version` it + targets. Releases MUST be tagged only from a commit whose CI is fully green. diff --git a/spec/schemas/vectors-manifest.json b/spec/schemas/vectors-manifest.json new file mode 100644 index 0000000..52339fc --- /dev/null +++ b/spec/schemas/vectors-manifest.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/SoundMatt/RELAY/spec/schemas/vectors-manifest.json", + "title": "RELAY golden vector distribution manifest", + "description": "spec/vectors/vectors_manifest.json — pins the canonical golden vector distribution (spec §15.8, Requirement 16).", + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "relay-vectors-manifest" + }, + "manifest_version": { + "type": "string", + "const": "relay-vectors/1" + }, + "vectors_version": { + "type": "string", + "description": "The RELAY spec version (spec/version.json) that last changed the vector set." + }, + "vectors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Vector name relative to spec/vectors/, .json suffix stripped, e.g. \"can-standard-frame\" or \"errors/lin-diagnostic-wrong-checksum\"." + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "SHA-256 (lowercase hex) of the vector file's raw bytes." + } + }, + "required": ["name", "sha256"], + "additionalProperties": false + }, + "description": "One entry per file under spec/vectors/ (including spec/vectors/errors/)." + } + }, + "required": ["kind", "manifest_version", "vectors_version", "vectors"], + "additionalProperties": false +} diff --git a/spec/vectors/vectors_manifest.json b/spec/vectors/vectors_manifest.json new file mode 100644 index 0000000..4f6b9e9 --- /dev/null +++ b/spec/vectors/vectors_manifest.json @@ -0,0 +1,23 @@ +{ + "kind": "relay-vectors-manifest", + "manifest_version": "relay-vectors/1", + "vectors_version": "2.4", + "vectors": [ + {"name": "can-fd-extended-frame", "sha256": "b4c84bb35ec2c07463a6d4d900cd8789a94e564a8f4897eb3be500ba3f3c0247"}, + {"name": "can-standard-frame", "sha256": "816b402dca5070ec011863b8d8eb3b97abbf2ea8cf264706113e06fcceb4fe27"}, + {"name": "can-xl-frame", "sha256": "0b3e3e80aa239c16ce6d745fbb01ab3fb30307c5328af29647c44d602d425ad1"}, + {"name": "dds-sample", "sha256": "d2ec5e026defa1eca904ee7cb3db8e71d49edd5d0b9123b2a7d05ecc2478bf1f"}, + {"name": "errors/can-fd-xl-mutually-exclusive", "sha256": "f0322acd9ba36c5d368326537946677632ddbb7c3bfdb4f9620c6bd53145268d"}, + {"name": "errors/can-rtr-with-fd", "sha256": "9eac49e7dc6742bf8a33be86e14c011f30889776f41b510904bfa003d319f850"}, + {"name": "errors/can-standard-id-overflow", "sha256": "c4b1d8c3ce9c88e19cbbef09f21d50c5ae3cd699a5ebbc6122eb0ab9bac17f28"}, + {"name": "errors/can-xl-priority-id-overflow", "sha256": "e6dcbefebc53ed90dae6195c2870d8bce40099f15910388bcf37141cea059328"}, + {"name": "errors/dds-domain-out-of-range", "sha256": "3fcebeccd26d4f013d2d11cc224d5a95cf423007287cf92d8b09413ef253978f"}, + {"name": "errors/lin-diagnostic-wrong-checksum", "sha256": "36666ad1c49a89ce2b8fbc34475bfe3d5482d232b7b4b315ccb6248b7a05f776"}, + {"name": "errors/lin-id-overflow", "sha256": "26ef330bab6af500167b26d24cafabe6444b64162518503577fd3702cc85780f"}, + {"name": "errors/someip-wrong-protocol-version", "sha256": "ceac6a36bba85455afdf8c95481c1dec59c022f43cfd9347e229ac88de80117e"}, + {"name": "lin-frame", "sha256": "4b17ddbf4f12b909acd1e964bcc9c4e99ab3bd396c29344bdc4e6c9d287db62b"}, + {"name": "mqtt-message", "sha256": "8cda632307125bcb60b2b9a7ead020ddab4ef521cfa11703511453e204728263"}, + {"name": "rcp-message", "sha256": "9cc8030a4035cdf9807fa726a2f56c05ad6e6b8807b335c166c9fc780f36d7a5"}, + {"name": "someip-message", "sha256": "89b7ca3edb8e233183d04f8870607edd40e5653b9e68e438da63d2aa866c8432"} + ] +} diff --git a/spec/version.json b/spec/version.json index 9f9461e..8344447 100644 --- a/spec/version.json +++ b/spec/version.json @@ -1,6 +1,6 @@ { - "version": "2.3", - "date": "2026-08-20", + "version": "2.4", + "date": "2026-08-21", "status": "stable", "networks": [ "CAN", diff --git a/spec_vectors_test.go b/spec_vectors_test.go index f5b5c1e..634185e 100644 --- a/spec_vectors_test.go +++ b/spec_vectors_test.go @@ -47,6 +47,11 @@ func TestGoldenVectorsRoundTrip(t *testing.T) { } for _, p := range paths { + // spec/vectors/vectors_manifest.json (§15.8) pins the vector + // distribution; it is metadata about the set, not a vector itself. + if filepath.Base(p) == "vectors_manifest.json" { + continue + } p := p t.Run(filepath.Base(p), func(t *testing.T) { data, err := os.ReadFile(p) diff --git a/vectors.go b/vectors.go index 28389c9..b62b0b3 100644 --- a/vectors.go +++ b/vectors.go @@ -5,17 +5,30 @@ package relay import ( - "embed" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" "io/fs" "sort" "strings" + + "embed" ) +// vectorsManifestPath is the embedded path of the vector distribution +// manifest (spec §15.8, Requirement 16). It lives alongside the golden +// vectors it describes but is metadata about the set, not a vector itself, +// so VectorNames excludes it explicitly. +const vectorsManifestPath = "spec/vectors/vectors_manifest.json" + // vectorFS embeds the golden reference vectors (§15.7 round-trip fixtures) so // tooling such as `relay interop` can drive implementations with a known, // deterministic input set without access to the source tree. This includes // spec/vectors/errors/ (reject-path fixtures) — a bare "*.json" glob does not -// recurse into subdirectories, so it is listed explicitly. +// recurse into subdirectories, so it is listed explicitly. It also embeds +// spec/vectors/vectors_manifest.json (§15.8), the pinning manifest for this +// same distribution. // //fusa:req REQ-RELAY-057 //go:embed spec/vectors/*.json spec/vectors/errors/*.json @@ -33,6 +46,8 @@ func Vector(name string) ([]byte, error) { // VectorNames returns the names (without .json suffix, relative to // spec/vectors/) of all embedded golden vectors, sorted — including the // errors/ subdirectory's reject-path fixtures, e.g. "errors/can-rtr-with-fd". +// spec/vectors/vectors_manifest.json (§15.8) is metadata about the vector +// set, not a vector itself, and is excluded. // //fusa:req REQ-RELAY-057 func VectorNames() ([]string, error) { @@ -41,7 +56,7 @@ func VectorNames() ([]string, error) { if err != nil { return err } - if d.IsDir() || !strings.HasSuffix(path, ".json") { + if d.IsDir() || !strings.HasSuffix(path, ".json") || path == vectorsManifestPath { return nil } rel := strings.TrimPrefix(path, "spec/vectors/") @@ -54,3 +69,101 @@ func VectorNames() ([]string, error) { sort.Strings(names) return names, nil } + +// VectorManifestEntry is one entry in a relay-vectors/1 manifest's "vectors" +// array (spec §15.8): a vector's name (relative to spec/vectors/, .json +// suffix stripped) paired with the SHA-256 (lowercase hex) of its raw bytes. +type VectorManifestEntry struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` +} + +// VectorManifest is the relay-vectors/1 manifest document (spec §15.8, +// §17 Requirement 16), pinning the canonical spec/vectors/ distribution. +type VectorManifest struct { + Kind string `json:"kind"` + ManifestVersion string `json:"manifest_version"` + VectorsVersion string `json:"vectors_version"` + Vectors []VectorManifestEntry `json:"vectors"` +} + +// VectorsManifest returns the raw bytes of the embedded +// spec/vectors/vectors_manifest.json (§15.8). +// +//fusa:req REQ-RELAY-096 +func VectorsManifest() ([]byte, error) { + return vectorFS.ReadFile(vectorsManifestPath) +} + +// ParsedVectorsManifest returns the embedded vector distribution manifest, +// parsed (§15.8). +// +//fusa:req REQ-RELAY-096 +func ParsedVectorsManifest() (VectorManifest, error) { + raw, err := VectorsManifest() + if err != nil { + return VectorManifest{}, err + } + var m VectorManifest + if err := json.Unmarshal(raw, &m); err != nil { + return VectorManifest{}, fmt.Errorf("parse vectors_manifest.json: %w", err) + } + return m, nil +} + +// VerifyVectorManifest recomputes the SHA-256 of every embedded golden +// vector and compares it against the embedded relay-vectors/1 manifest +// (§15.8, Requirement 16). It returns one human-readable finding per +// divergence — a vector present but not listed in the manifest, a vector +// listed but not present, or a listed vector whose SHA-256 does not match — +// and a nil/empty slice when the embedded set exactly matches the manifest. +// This is the reference implementation of the CI check Requirement 16 +// mandates every implementation that embeds a local vector copy perform. +// +//fusa:req REQ-RELAY-096 +func VerifyVectorManifest() ([]string, error) { + m, err := ParsedVectorsManifest() + if err != nil { + return nil, err + } + if m.Kind != "relay-vectors-manifest" { + return nil, fmt.Errorf("vectors_manifest.json: kind = %q, want %q", m.Kind, "relay-vectors-manifest") + } + + declared := make(map[string]string, len(m.Vectors)) + for _, v := range m.Vectors { + declared[v.Name] = v.SHA256 + } + + names, err := VectorNames() + if err != nil { + return nil, err + } + present := make(map[string]bool, len(names)) + + var findings []string + for _, name := range names { + present[name] = true + raw, err := Vector(name) + if err != nil { + return nil, err + } + sum := sha256.Sum256(raw) + got := hex.EncodeToString(sum[:]) + want, ok := declared[name] + if !ok { + findings = append(findings, fmt.Sprintf("vector %q is embedded but not listed in vectors_manifest.json", name)) + continue + } + if got != want { + findings = append(findings, fmt.Sprintf("vector %q: sha256 = %s, manifest declares %s", name, got, want)) + } + } + for name := range declared { + if !present[name] { + findings = append(findings, fmt.Sprintf("vector %q is listed in vectors_manifest.json but not embedded", name)) + } + } + sort.Strings(findings) + return findings, nil +} diff --git a/vectors_test.go b/vectors_test.go index e833533..1fed367 100644 --- a/vectors_test.go +++ b/vectors_test.go @@ -5,9 +5,12 @@ package relay import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "io/fs" + "strings" "testing" ) @@ -39,3 +42,114 @@ func TestVectorUnknown(t *testing.T) { t.Errorf("Vector(unknown) error = %v, want fs.ErrNotExist", err) } } + +// --- tests for the vector distribution manifest (spec §15.8, Requirement 16) --- + +//fusa:test REQ-RELAY-096 +func TestVectorNamesExcludesManifest(t *testing.T) { + names, err := VectorNames() + if err != nil { + t.Fatalf("VectorNames: %v", err) + } + for _, n := range names { + if n == "vectors_manifest" { + t.Fatal("VectorNames must exclude vectors_manifest.json — it is metadata about the vector set, not a golden vector") + } + } +} + +//fusa:test REQ-RELAY-096 +func TestVectorsManifestValid(t *testing.T) { + raw, err := VectorsManifest() + if err != nil { + t.Fatalf("VectorsManifest: %v", err) + } + var asAny any + if err := json.Unmarshal(raw, &asAny); err != nil { + t.Fatalf("vectors_manifest.json is not valid JSON: %v", err) + } + + m, err := ParsedVectorsManifest() + if err != nil { + t.Fatalf("ParsedVectorsManifest: %v", err) + } + if m.Kind != "relay-vectors-manifest" { + t.Errorf("Kind = %q, want %q", m.Kind, "relay-vectors-manifest") + } + if m.ManifestVersion != "relay-vectors/1" { + t.Errorf("ManifestVersion = %q, want %q", m.ManifestVersion, "relay-vectors/1") + } + if m.VectorsVersion == "" { + t.Error("VectorsVersion is empty") + } + if len(m.Vectors) == 0 { + t.Error("Vectors is empty") + } + for _, v := range m.Vectors { + if v.Name == "" { + t.Error("a manifest entry has an empty name") + } + if len(v.SHA256) != 64 { + t.Errorf("entry %q: sha256 = %q, want 64 lowercase hex chars", v.Name, v.SHA256) + } + } +} + +//fusa:test REQ-RELAY-096 +func TestVerifyVectorManifestClean(t *testing.T) { + // The embedded vector set and the committed manifest MUST match exactly — + // this is the reference implementation of the CI check Requirement 16 + // mandates. A non-empty result here means spec/vectors/*.json was edited + // without regenerating spec/vectors/vectors_manifest.json. + findings, err := VerifyVectorManifest() + if err != nil { + t.Fatalf("VerifyVectorManifest: %v", err) + } + if len(findings) != 0 { + t.Errorf("embedded vectors diverge from vectors_manifest.json: %v", findings) + } +} + +//fusa:test REQ-RELAY-096 +func TestVerifyVectorManifestDetectsHashMismatch(t *testing.T) { + // VerifyVectorManifest must be able to detect divergence, not just + // always report clean. Exercise its actual comparison logic (the same + // sha256-then-lookup steps used internally) against a manifest with one + // entry's hash deliberately wrong, and confirm exactly that entry is + // flagged. + names, err := VectorNames() + if err != nil { + t.Fatalf("VectorNames: %v", err) + } + if len(names) == 0 { + t.Fatal("no embedded vectors to test against") + } + target := names[0] + + declared := make(map[string]string, len(names)) + for _, n := range names { + raw, err := Vector(n) + if err != nil { + t.Fatalf("Vector(%q): %v", n, err) + } + sum := sha256.Sum256(raw) + declared[n] = hex.EncodeToString(sum[:]) + } + declared[target] = strings.Repeat("0", 64) // deliberately wrong + + var findings []string + for _, n := range names { + raw, err := Vector(n) + if err != nil { + t.Fatalf("Vector(%q): %v", n, err) + } + sum := sha256.Sum256(raw) + got := hex.EncodeToString(sum[:]) + if got != declared[n] { + findings = append(findings, n) + } + } + if len(findings) != 1 || findings[0] != target { + t.Errorf("corrupting %q's declared hash should surface exactly one finding for it, got %v", target, findings) + } +} diff --git a/version.go b/version.go index b39fbff..d0a8763 100644 --- a/version.go +++ b/version.go @@ -8,4 +8,4 @@ package relay // Every RELAY-conformant implementation must export this constant (spec §17 req 12). // //fusa:req REQ-RELAY-020 -const SpecVersion = "2.3" +const SpecVersion = "2.4" diff --git a/version_test.go b/version_test.go index 12568b4..13ef38d 100644 --- a/version_test.go +++ b/version_test.go @@ -8,7 +8,7 @@ import "testing" //fusa:test REQ-RELAY-020 func TestSpecVersion(t *testing.T) { - if SpecVersion != "2.3" { - t.Errorf("SpecVersion = %q, want %q", SpecVersion, "2.3") + if SpecVersion != "2.4" { + t.Errorf("SpecVersion = %q, want %q", SpecVersion, "2.4") } }