Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .fusa-reqs.json
Original file line number Diff line number Diff line change
Expand Up @@ -932,6 +932,16 @@
"verification": "test",
"safety_goal": "SG-006",
"asil": "QM"
},
{
"id": "REQ-RELAY-099",
"title": "Retired-capability enforcement (relay conform, spec sec-3.2)",
"text": "spec/version.json MAY carry a retired[] array (spec sec-3.2), each entry a {name, since, removal, reason} record. relay conform MUST FAIL a capabilities document (sec-12.2) whose declared spec_version is at or past a retired[] entry's removal version and whose features or commands still lists that entry's name (spec sec-17 Requirement 17). The check is sourced from relay conform's own embedded spec/version.json, not the attested binary's -- a black-box invocation cannot introspect the target's retired list, only compare its declared spec_version and capabilities against the reference list relay conform itself ships.",
"category": "functional",
"criticality": "medium",
"verification": "test",
"safety_goal": "SG-006",
"asil": "ASIL-B"
}
]
}
119 changes: 117 additions & 2 deletions cmd/relay/conform.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"io"
"os"
"os/exec"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -282,15 +283,25 @@ func buildManifest(binary string) conformManifest {
sFindings = validateStatusDoc(statusJSON)
}

// capsUnreachable is true when the capabilities command couldn't even be
// run/parsed at all (§17.7) — a total failure that subsumes every
// narrower capabilities-doc check below.
capsUnreachable := hasFailWithReq(cFindings, "§17.7")

req1 := statusPass
// Requirement 1 (protocol declaration) spans both documents: spec_version
// shape (version doc) and the capabilities doc's own protocol-null check
// (§12.2, gated on multi_protocol — see validateCapabilitiesDoc).
if hasFail(vFindings) || hasFailWithReq(cFindings, "§12.2") {
if hasFail(vFindings) || capsUnreachable || hasFailWithReq(cFindings, "§12.2") {
req1 = statusFail
}

req6 := statusPass
if hasFail(cFindings) {
// Requirement 6 (application interface) is specifically the adapt=false
// check (§17.6) — filtered so an unrelated capabilities-doc FAIL (e.g.
// Requirement 17's retirement check) doesn't misreport this requirement,
// but still FAILs if the capabilities doc couldn't be fetched at all.
if capsUnreachable || hasFailWithReq(cFindings, "§17.6") {
req6 = statusFail
}
req7 := statusPass
Expand All @@ -301,6 +312,10 @@ func buildManifest(binary string) conformManifest {
if hasFail(vFindings) {
req12 = statusFail
}
req17 := statusPass
if capsUnreachable || hasFailWithReq(cFindings, "§3.2") {
req17 = statusFail
}

m.Requirements = []manifestRequirement{
{1, req1, verifierRelayConform},
Expand All @@ -319,6 +334,7 @@ func buildManifest(binary string) conformManifest {
{14, statusNotObservable, verifierCI},
{15, statusNotObservable, verifierManifestSplit},
{16, statusNotObservable, verifierCI},
{17, req17, verifierRelayConform},
}

m.Overall = statusPass
Expand Down Expand Up @@ -538,6 +554,105 @@ func validateCapabilitiesDoc(data []byte) []conformFinding {
}
}

// §3.2 / §17 Requirement 17: a capabilities doc MUST NOT still advertise a
// retired capability at or past its removal version.
if specVersion, ok := doc["spec_version"].(string); ok {
fs = append(fs, checkRetiredCapabilities(specVersion, doc, loadRetiredCapabilities())...)
}

return fs
}

// retiredEntry is one entry in spec/version.json's retired[]/deprecated[]
// arrays (§3.2).
type retiredEntry struct {
Name string `json:"name"`
Since string `json:"since"`
Removal string `json:"removal"`
Reason string `json:"reason"`
}

// loadRetiredCapabilities parses RELAY's own embedded spec/version.json and
// returns its retired[] entries (§3.2). Returns nil, not an error, if the
// embedded document is unparseable — this check degrades to a no-op rather
// than failing every conform invocation on an unrelated defect.
func loadRetiredCapabilities() []retiredEntry {
raw, err := relay.Evidence("version")
if err != nil {
return nil
}
var meta struct {
Retired []retiredEntry `json:"retired"`
}
if err := json.Unmarshal(raw, &meta); err != nil {
return nil
}
return meta.Retired
}

// parseSpecVersion parses a "MAJOR.MINOR" spec version string.
func parseSpecVersion(s string) (major, minor int, ok bool) {
parts := strings.SplitN(s, ".", 2)
if len(parts) != 2 {
return 0, 0, false
}
maj, err1 := strconv.Atoi(parts[0])
min, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil {
return 0, 0, false
}
return maj, min, true
}

// specVersionAtLeast reports whether a >= b, both "MAJOR.MINOR" strings.
// Returns false (fail-safe: does not trigger retirement enforcement) if
// either string cannot be parsed.
func specVersionAtLeast(a, b string) bool {
aMaj, aMin, aOK := parseSpecVersion(a)
bMaj, bMin, bOK := parseSpecVersion(b)
if !aOK || !bOK {
return false
}
if aMaj != bMaj {
return aMaj > bMaj
}
return aMin >= bMin
}

// checkRetiredCapabilities cross-references a binary's declared spec_version
// and capabilities (features/commands) against a retired[] list (§3.2, §17
// Requirement 17), FAILing any capability still advertised at or past its
// recorded removal version.
//
//fusa:req REQ-RELAY-099
func checkRetiredCapabilities(specVersion string, capsDoc map[string]interface{}, retired []retiredEntry) []conformFinding {
if specVersion == "" || len(retired) == 0 {
return nil
}
named := map[string]bool{}
if cmds, ok := capsDoc["commands"].([]interface{}); ok {
for _, c := range cmds {
if s, ok := c.(string); ok {
named[s] = true
}
}
}
if feats, ok := capsDoc["features"].([]interface{}); ok {
for _, f := range feats {
if s, ok := f.(string); ok {
named[s] = true
}
}
}
var fs []conformFinding
for _, r := range retired {
if !specVersionAtLeast(specVersion, r.Removal) {
continue
}
if named[r.Name] {
fs = append(fs, fail("§3.2", "capabilities doc: declares retired capability %q, which MUST be removed at spec_version %s (removal version %s)", r.Name, specVersion, r.Removal))
}
}
return fs
}

Expand Down
157 changes: 150 additions & 7 deletions cmd/relay/conform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,8 @@ func TestBuildManifestSelf(t *testing.T) {
if m.SpecVersion == "" {
t.Error("SpecVersion is empty")
}
if len(m.Requirements) != 16 {
t.Fatalf("len(Requirements) = %d, want 16", len(m.Requirements))
if len(m.Requirements) != 17 {
t.Fatalf("len(Requirements) = %d, want 17", len(m.Requirements))
}
for i, r := range m.Requirements {
if r.ID != i+1 {
Expand All @@ -398,7 +398,7 @@ func TestBuildManifestSelf(t *testing.T) {
// §17.2: a manifest MUST NOT report PASS for a requirement relay
// conform cannot actually observe. Only 1, 6, 7, 12 may be PASS/FAIL;
// everything else must never be reported PASS.
observable := map[int]bool{1: true, 6: true, 7: true, 12: true}
observable := map[int]bool{1: true, 6: true, 7: true, 12: true, 17: true}
if !observable[r.ID] && r.Status == statusPass {
t.Errorf("Requirements[%d] (id=%d) reports PASS but is not in the observable set", i, r.ID)
}
Expand All @@ -415,6 +415,8 @@ func TestBuildManifestNotObservableDefaults(t *testing.T) {
m := buildManifest(bin)

wantNotObservable := []int{2, 3, 4, 5, 8, 9, 10, 11, 13, 14, 15, 16}
// Requirement 17 is fully observable (see TestBuildManifestSelf's
// observable set), so it is deliberately excluded from this list.
byID := map[int]manifestRequirement{}
for _, r := range m.Requirements {
byID[r.ID] = r
Expand Down Expand Up @@ -508,8 +510,8 @@ func TestRunConformManifestFlag(t *testing.T) {
if m.Overall != statusPass {
t.Errorf("Overall = %s, want %s", m.Overall, statusPass)
}
if len(m.Requirements) != 16 {
t.Errorf("len(Requirements) = %d, want 16", len(m.Requirements))
if len(m.Requirements) != 17 {
t.Errorf("len(Requirements) = %d, want 17", len(m.Requirements))
}
}

Expand Down Expand Up @@ -570,8 +572,8 @@ func TestBuildAttestationSelf(t *testing.T) {
if a.Predicate.ConformanceManifest.Kind != "relay-conform-manifest" {
t.Errorf("Predicate.ConformanceManifest.Kind = %q, want relay-conform-manifest", a.Predicate.ConformanceManifest.Kind)
}
if len(a.Predicate.ConformanceManifest.Requirements) != 16 {
t.Errorf("len(Predicate.ConformanceManifest.Requirements) = %d, want 16", len(a.Predicate.ConformanceManifest.Requirements))
if len(a.Predicate.ConformanceManifest.Requirements) != 17 {
t.Errorf("len(Predicate.ConformanceManifest.Requirements) = %d, want 17", len(a.Predicate.ConformanceManifest.Requirements))
}
// §20.6: MUST be false — this type has no signing mechanism at all.
if a.Predicate.Signed {
Expand Down Expand Up @@ -671,6 +673,147 @@ func TestRunConformAttestationFlagFail(t *testing.T) {
}
}

// --- tests for checkRetiredCapabilities / specVersionAtLeast (spec §3.2, Requirement 17) ---

//fusa:test REQ-RELAY-099
func TestSpecVersionAtLeast(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"2.9", "2.9", true},
{"2.10", "2.9", true}, // numeric, not lexicographic, comparison
{"2.9", "2.10", false}, // lexicographically "2.9" > "2.10" but numerically it is not
{"3.0", "2.99", true},
{"2.8", "2.9", false},
{"garbage", "2.9", false}, // unparseable: fail-safe, does not trigger enforcement
{"2.9", "garbage", false},
}
for _, c := range cases {
if got := specVersionAtLeast(c.a, c.b); got != c.want {
t.Errorf("specVersionAtLeast(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}

//fusa:test REQ-RELAY-099
func TestCheckRetiredCapabilitiesFails(t *testing.T) {
retired := []retiredEntry{
{Name: "old-thing", Since: "2.7", Removal: "2.9", Reason: "superseded"},
}
capsDoc := map[string]interface{}{
"features": []interface{}{"old-thing", "fd"},
}
fs := checkRetiredCapabilities("2.9", capsDoc, retired)
if len(fs) != 1 {
t.Fatalf("expected exactly one finding, got %d: %+v", len(fs), fs)
}
if fs[0].Severity != sevFail {
t.Errorf("Severity = %s, want %s", fs[0].Severity, sevFail)
}
if fs[0].Req != "§3.2" {
t.Errorf("Req = %q, want %q", fs[0].Req, "§3.2")
}
}

//fusa:test REQ-RELAY-099
func TestCheckRetiredCapabilitiesBeforeRemoval(t *testing.T) {
// spec_version is still before the removal version — retirement window
// hasn't closed yet, so this MUST NOT FAIL (§3.2 permits it, compat-flagged,
// between since and removal).
retired := []retiredEntry{
{Name: "old-thing", Since: "2.7", Removal: "2.9", Reason: "superseded"},
}
capsDoc := map[string]interface{}{
"features": []interface{}{"old-thing"},
}
fs := checkRetiredCapabilities("2.8", capsDoc, retired)
if len(fs) != 0 {
t.Errorf("expected no findings before removal version, got %+v", fs)
}
}

//fusa:test REQ-RELAY-099
func TestCheckRetiredCapabilitiesNotDeclared(t *testing.T) {
// Past removal, but the capabilities doc doesn't actually declare the
// retired name — nothing to FAIL.
retired := []retiredEntry{
{Name: "old-thing", Since: "2.7", Removal: "2.9", Reason: "superseded"},
}
capsDoc := map[string]interface{}{
"features": []interface{}{"fd"},
"commands": []interface{}{"version", "capabilities", "status"},
}
fs := checkRetiredCapabilities("2.9", capsDoc, retired)
if len(fs) != 0 {
t.Errorf("expected no findings when the retired name isn't declared, got %+v", fs)
}
}

//fusa:test REQ-RELAY-099
func TestCheckRetiredCapabilitiesCommands(t *testing.T) {
// A retired command name (not just a feature) MUST also be caught.
retired := []retiredEntry{
{Name: "old-command", Since: "2.7", Removal: "2.9", Reason: "superseded"},
}
capsDoc := map[string]interface{}{
"commands": []interface{}{"version", "capabilities", "status", "old-command"},
}
fs := checkRetiredCapabilities("2.9", capsDoc, retired)
if len(fs) != 1 {
t.Fatalf("expected exactly one finding, got %d: %+v", len(fs), fs)
}
}

//fusa:test REQ-RELAY-099
func TestCheckRetiredCapabilitiesEmptyInputs(t *testing.T) {
if fs := checkRetiredCapabilities("", map[string]interface{}{"features": []interface{}{"x"}}, []retiredEntry{{Name: "x", Removal: "1.0"}}); len(fs) != 0 {
t.Errorf("empty spec_version should produce no findings, got %+v", fs)
}
if fs := checkRetiredCapabilities("2.9", map[string]interface{}{"features": []interface{}{"x"}}, nil); len(fs) != 0 {
t.Errorf("nil retired list should produce no findings, got %+v", fs)
}
}

//fusa:test REQ-RELAY-099
func TestLoadRetiredCapabilitiesRealManifestEmpty(t *testing.T) {
// RELAY's own shipped spec/version.json retired[] is empty (§3.2 governs
// retirements declared from its introduction onward; the historical RCP
// placeholder-model retirement predates it and is not retrofitted). This
// is a sanity check that the loader itself works against the real
// embedded document, not a synthetic fixture.
got := loadRetiredCapabilities()
if len(got) != 0 {
t.Errorf("loadRetiredCapabilities() = %+v, want empty (nothing retired yet)", got)
}
}

//fusa:test REQ-RELAY-099
func TestValidateCapabilitiesDocRetiredCapability(t *testing.T) {
// End-to-end through validateCapabilitiesDoc requires a real retired[]
// entry in the embedded spec/version.json, which is currently empty by
// design (see TestLoadRetiredCapabilitiesRealManifestEmpty) — so this
// test exercises checkRetiredCapabilities directly with the exact shape
// validateCapabilitiesDoc would pass it, confirming the wiring contract
// (field names, types) matches what a real capabilities doc provides.
data := []byte(`{
"kind":"capabilities","tool":"go-can","protocol":"CAN","protocol_int":1,
"version":"9.0.0","spec_version":"99.0",
"commands":["version","capabilities","status"],
"transports":[],"features":["retired-example"],"interfaces":[],
"optional_interfaces":[],"adapt":true
}`)
var doc map[string]interface{}
if err := json.Unmarshal(data, &doc); err != nil {
t.Fatalf("unmarshal fixture: %v", err)
}
retired := []retiredEntry{{Name: "retired-example", Since: "2.0", Removal: "50.0", Reason: "test fixture"}}
fs := checkRetiredCapabilities(doc["spec_version"].(string), doc, retired)
if len(fs) != 1 {
t.Fatalf("expected one finding for a capabilities doc past a retired name's removal version, got %d: %+v", len(fs), fs)
}
}

func countBySeverity(fs []conformFinding, sev conformSeverity) int {
n := 0
for _, f := range fs {
Expand Down
2 changes: 1 addition & 1 deletion cmd/relay/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
relay "github.com/SoundMatt/RELAY/v2"
)

const toolVersion = "2.6.0"
const toolVersion = "2.7.0"

func main() {
if err := run(os.Stdout, os.Stderr, os.Args[1:]); err != nil {
Expand Down
Loading
Loading