diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 6d0877d..a3b79a5 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -38,6 +38,9 @@ Tests that bind an ID from the Janus invariant registry (`jfa-conformance-suite. | Registry ID | Test | What it adds beyond the package's existing tests | |---|---|---| +| **REC-witness-minimum** — fewer than two independent witnesses forces the unfederated label | `internal/record` `TestInvariant_REC_witness_minimum_UnfederatedLabel` | The label is computed: it stays on for zero or one witness, and when the "second" is the operator, the same witness twice, a forged signature, or a cosignature of another checkpoint; it drops only for two verified, operator-independent witnesses. Every served struct with `countersignatures` also carries a `stand_in` bool, set from a `StandIn(…)` call, never a constant. Named exception: the relay's `BundleMsg`, which by design leaves counting to readers | +| **COV-adjudicators** — adjudication conduct ratable by the parties to the adjudication | `internal/consumerapi` `TestInvariant_COV_adjudicators_PartiesRateTheAdjudicator` | Both complainant and respondent can rate the operator's conduct; a stranger, a non-party trader, a party on an unclaimed exchange, and the operator itself cannot; the relation rates only the adjudicator; ratings form their own distribution, never pooled into trade; only the operator answers. **Residual:** the cross-platform half (witnesses rated as adjudicators) awaits witness-layer adjudication, which is not built | +| **COV-operators-adjudicate** — platform operators adjudicate | `internal/dispute` `TestInvariant_COV_operators_adjudicate_OperatorRulesOnItsPlatform` | Only a threshold of the platform Charter's adjudicators resolves a case — never a party, both parties, a witness, or a stranger; rulings are bound to their platform; the Registry API is pinned and `StateResolved` is set in exactly one place; a withdrawal is the complainant's alone and carries no finding. Out of scope, named: barring a rostered adjudicator from its own case is COV-operator-dispute-witnesses (instrument-bound) and is not enforced here | | **L1** — each exchange moves two balances netting to zero | `internal/economy` `TestInvariant_L1_ZeroSumBalance` | Concurrent payers across two ledgers sharing one store; per-record netting; replay reproduces every balance | | **L2** — earned never bought, never redeemable for fiat | `internal/economy` `TestInvariant_L2_EarnedNeverBought` | The ledger API, record union, and every record field are pinned to allowlists; the ledger imports only the standard library and canon; no buy/redeem/cash-out/payout verb or route exists in the module, and no function both touches the fiat rail and builds a spend; at runtime escrow holds no credit and a balance rises only by a payer-signed spend | | **L3** — sovereign units, no conversion | `internal/economy` `TestInvariant_L3_SovereignUnits` | One key yields unlinkable accounts per platform; a spend signed for one platform never posts on, or re-points to, another, and a foreign spend in a store makes it refuse to open; platform identity lives in no governed field; no conversion, rate, peg, or bridge surface exists | diff --git a/internal/consumerapi/conformance_test.go b/internal/consumerapi/conformance_test.go new file mode 100644 index 0000000..50df367 --- /dev/null +++ b/internal/consumerapi/conformance_test.go @@ -0,0 +1,221 @@ +package consumerapi + +// JFA conformance bindings at the composition root, where the covenant's +// adjudication anchor is wired to the dispute registry. +// +// Test names carry the Janus registry ID they bind (jfa-conformance-suite.py +// in NTARI-RAND/Janus), so `jfa-conformance-suite.py --bindings` and a CI log +// parser can map a PASS/FAIL back to the invariant: TestInvariant__, +// with any "-" in the ID written "_". + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "net/http" + "testing" + "time" + + "github.com/NTARI-RAND/Cloudy/internal/covenant" + "github.com/NTARI-RAND/Cloudy/internal/dispute" + "github.com/NTARI-RAND/Cloudy/internal/record" +) + +// TestInvariant_COV_adjudicators_PartiesRateTheAdjudicator binds registry ID +// COV-adjudicators: "adjudication conduct ratable by the parties to the +// adjudication — the operator on a platform, the witnesses across +// platforms." +// +// On a platform the operator adjudicates (COV-operators-adjudicate), so the +// operator is the referee who must stand inside the reputation system it +// enforces. Over the public API this binding proves: +// +// - BOTH parties to a claim — complainant and respondent — can rate the +// operator's adjudication conduct, at any level, a No Trust included; +// - nobody else can: not a stranger, not a member who traded on the +// exchange but is no party to the claim, not anyone on an exchange with +// no claim, and not the operator rating itself; +// - only the adjudicator can be rated in this relation: a party cannot use +// it to rate the other party; +// - the ratings land in the operator's standing as their own typed +// distribution, never pooled into trade, with the harm counted; +// - the operator can answer each rating, and only the operator can. +// +// Residual, named rather than hidden: the cross-platform half — witnesses +// rated by the parties to a dispute they adjudicate — has no code to test +// yet, because cross-platform adjudication at the witness layer +// (COV-witness-adjudication) is not built. The adjudication anchor here +// accepts only the platform operator as subject; when witness adjudication +// lands, that anchor must widen to the panel and this binding must grow. +func TestInvariant_COV_adjudicators_PartiesRateTheAdjudicator(t *testing.T) { + s, err := NewServer(platform) + if err != nil { + t.Fatal(err) + } + h := s.Handler() + op := s.operatorMember + + pubA, privA := key(0x61) // complainant + pubB, privB := key(0x62) // respondent + pubC, privC := key(0x63) // stranger + pubD, privD := key(0x64) // traded with A, never party to the claim + for _, m := range []struct { + pub ed25519.PublicKey + priv ed25519.PrivateKey + }{{pubA, privA}, {pubB, privB}, {pubC, privC}, {pubD, privD}} { + registerMember(t, h, m.pub, m.priv) + } + disputed := sealExchange(t, h, pubA, privA, pubB, privB) + openClaim(t, h, pubA, privA, pubB, disputed) + unclaimed := sealExchange(t, h, pubA, privA, pubD, privD) + + mem := func(pub ed25519.PublicKey) covenant.MemberID { return covenant.MemberIDFor(platform, pub) } + + // Both parties rate the operator's conduct — one with the harm level. + idA := rate(t, h, privA, mem(pubA), op, disputed, covenant.LevelNoTrust, http.StatusOK) + idB := rate(t, h, privB, mem(pubB), op, disputed, covenant.LevelBasicSatisfaction, http.StatusOK) + + // Nobody else can. + refused := []struct { + name string + priv ed25519.PrivateKey + assessor covenant.MemberID + subject covenant.MemberID + exchange string + }{ + {"a stranger", privC, mem(pubC), op, disputed}, + {"a trader who is no party to the claim", privD, mem(pubD), op, disputed}, + {"a party, on an exchange with no claim", privA, mem(pubA), op, unclaimed}, + {"the other trader on that unclaimed exchange", privD, mem(pubD), op, unclaimed}, + {"the operator, rating itself", s.operatorPriv, op, op, disputed}, + {"a party rating the other party as if adjudicator", privA, mem(pubA), mem(pubB), disputed}, + } + // Each refusal uses a category no assessor has used yet, so the only + // possible reason for a 400 is the anchor (a duplicate would be 409). + for _, r := range refused { + r := r + t.Run("refused: "+r.name, func(t *testing.T) { + rateIn(t, h, r.priv, r.assessor, r.subject, r.exchange, "reliability", covenant.LevelDelight, http.StatusBadRequest) + }) + } + + // The ratings are the operator's own typed distribution. + rec, body := do(t, h, "GET", "/api/v1/members/"+string(op)+"/standing", nil) + if rec.Code != http.StatusOK { + t.Fatalf("standing: %d", rec.Code) + } + rels := body["relations"].(map[string]any) + conduct := rels["adjudication-conduct"].(map[string]any) + overall := conduct["overall"].(map[string]any) + if overall["total"].(float64) != 2 { + t.Errorf("adjudication-conduct total = %v, want 2 (both parties, nobody else)", overall["total"]) + } + counts := overall["counts"].(map[string]any) + if counts[covenant.LevelNoTrust.String()].(float64) != 1 || counts[covenant.LevelBasicSatisfaction.String()].(float64) != 1 { + t.Errorf("adjudication-conduct counts = %v, want one No Trust and one Basic Satisfaction", counts) + } + if conduct["harm"].(float64) != 1 { + t.Errorf("adjudication-conduct harm = %v, want 1", conduct["harm"]) + } + if trade := rels["trade"].(map[string]any)["overall"].(map[string]any); trade["total"].(float64) != 0 { + t.Errorf("trade total = %v, want 0: conduct ratings must never pool into trade", trade["total"]) + } + + // The operator answers each rating; a party cannot answer for it. + answer(t, h, privA, mem(pubA), idA, http.StatusBadRequest) + answer(t, h, s.operatorPriv, op, idA, http.StatusOK) + answer(t, h, s.operatorPriv, op, idB, http.StatusOK) +} + +// openClaim files a complainant-signed dispute over exchange through the API. +func openClaim(t *testing.T, h http.Handler, pubA ed25519.PublicKey, privA ed25519.PrivateKey, pubB ed25519.PublicKey, exchange string) { + t.Helper() + exRaw, _ := hex.DecodeString(exchange) + var ex dispute.ExchangeRef + copy(ex[:], exRaw) + o, err := dispute.NewOpening(platform, pubA, pubB, ex, sha256.Sum256([]byte("grievance")), time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if err := o.Sign(privA); err != nil { + t.Fatal(err) + } + oid := o.ID() + typeHash := sha256.Sum256([]byte("trade-harm")) + commitment := record.FilingCommitment{ + Claim: record.Hash(oid), Exchange: record.Hash(ex), TypeHash: record.Hash(typeHash), + At: o.OpenedAt, Filer: pubA, + } + commitment.Sign(privA) + req := openDisputeRequest{ + Complainant: hex.EncodeToString(pubA), + Respondent: hex.EncodeToString(pubB), + Exchange: exchange, + ReasonHash: hex.EncodeToString(o.ReasonHash[:]), + Nonce: hex.EncodeToString(o.Nonce[:]), + OpenedAt: o.OpenedAt.Format(time.RFC3339Nano), + Signature: hex.EncodeToString(o.Signature), + } + req.Filing.TypeHash = hex.EncodeToString(typeHash[:]) + req.Filing.At = o.OpenedAt.Format(time.RFC3339Nano) + req.Filing.Signature = hex.EncodeToString(commitment.Signature) + if rec, _ := do(t, h, "POST", "/api/v1/disputes", req); rec.Code != http.StatusOK { + t.Fatalf("open dispute: %d %s", rec.Code, rec.Body.String()) + } +} + +// rate posts an adjudication-conduct assessment and checks the status code; +// it returns the assessment ID on success. +func rate(t *testing.T, h http.Handler, priv ed25519.PrivateKey, assessor, subject covenant.MemberID, exchange string, level covenant.Level, want int) string { + return rateIn(t, h, priv, assessor, subject, exchange, "support", level, want) +} + +// rateIn is rate with an explicit category. The covenant de-duplicates on +// (assessor, exchange, relation, category), so a refusal case must use a +// category the same assessor has not already used on that exchange — +// otherwise it would be refused as a duplicate and pass for the wrong reason. +func rateIn(t *testing.T, h http.Handler, priv ed25519.PrivateKey, assessor, subject covenant.MemberID, exchange, category string, level covenant.Level, want int) string { + t.Helper() + exRaw, _ := hex.DecodeString(exchange) + var ex covenant.ExchangeRef + copy(ex[:], exRaw) + a := covenant.Assessment{ + Assessor: assessor, Subject: subject, Exchange: ex, + Relation: covenant.RelationAdjudicationConduct, Category: category, + Level: level, IssuedAt: time.Now().UTC(), + } + req := assessmentRequest{ + Assessor: string(assessor), Subject: string(subject), Exchange: exchange, + Relation: string(a.Relation), Category: a.Category, Level: int8(level), + IssuedAt: a.IssuedAt.Format(time.RFC3339Nano), + } + if level == covenant.LevelNoTrust { + a.CommentHash = sha256.Sum256([]byte("justifying comment, member-local")) + req.CommentHash = hex.EncodeToString(a.CommentHash[:]) + } + a.Sign(priv) + req.Signature = hex.EncodeToString(a.Signature) + rec, body := do(t, h, "POST", "/api/v1/assessments", req) + if rec.Code != want { + t.Fatalf("conduct rating by %s of %s on %s…: code %d, want %d (%s)", assessor[:8], subject[:8], exchange[:8], rec.Code, want, rec.Body.String()) + } + id, _ := body["id"].(string) + return id +} + +// answer posts an answer to assessment id and checks the status code. +func answer(t *testing.T, h http.Handler, priv ed25519.PrivateKey, answerer covenant.MemberID, id string, want int) { + t.Helper() + digest := sha256.Sum256([]byte("answer, member-local")) + an := covenant.Answer{Answerer: answerer, AnswerHash: digest, IssuedAt: time.Now().UTC()} + raw, _ := hex.DecodeString(id) + copy(an.Assessment[:], raw) + an.Sign(priv) + rec, _ := do(t, h, "POST", "/api/v1/assessments/"+id+"/answers", answerRequest{ + Answerer: string(answerer), AnswerHash: hex.EncodeToString(digest[:]), + IssuedAt: an.IssuedAt.Format(time.RFC3339Nano), Signature: hex.EncodeToString(an.Signature), + }) + if rec.Code != want { + t.Fatalf("answer by %s…: code %d, want %d (%s)", answerer[:8], rec.Code, want, rec.Body.String()) + } +} diff --git a/internal/dispute/conformance_test.go b/internal/dispute/conformance_test.go new file mode 100644 index 0000000..e4ddc1f --- /dev/null +++ b/internal/dispute/conformance_test.go @@ -0,0 +1,210 @@ +package dispute + +// JFA conformance bindings for the dispute layer. +// +// Test names carry the Janus registry ID they bind (jfa-conformance-suite.py +// in NTARI-RAND/Janus), so `jfa-conformance-suite.py --bindings` and a CI log +// parser can map a PASS/FAIL back to the invariant: TestInvariant__, +// with any "-" in the ID written "_". + +import ( + "crypto/ed25519" + "crypto/sha256" + "errors" + "go/ast" + "go/parser" + "go/token" + "os" + "reflect" + "strings" + "testing" + "time" +) + +// TestInvariant_COV_operators_adjudicate_OperatorRulesOnItsPlatform binds +// registry ID COV-operators-adjudicate: "adjudication of apparent covenant +// breaches performed by platform operators." +// +// The deciding role is the platform's Charter — the operator's own +// adjudicator roster, configured at the composition root for exactly one +// platform. This binding proves that role cannot be taken by anyone else: +// +// - only a threshold of the Charter's adjudicators resolves a case; the +// complainant, the respondent, both of them together, a witness, or a +// stranger cannot, however many sign; +// - a ruling is bound to its platform: another platform's ruling, or +// another platform's roster, never resolves a case here; +// - Rule is the only path to a resolution: the Registry's whole API is +// pinned, a Withdrawal is the complainant's alone and ends the case +// without a finding, and nothing else moves a case's state. +// +// Out of scope here, named: whether an adjudicator on the roster is barred +// from ruling on a case to which it is itself a party is +// COV-operator-dispute-witnesses, an instrument-bound invariant (P1-001 +// §10.9), and is not checked by this package today. +func TestInvariant_COV_operators_adjudicate_OperatorRulesOnItsPlatform(t *testing.T) { + t.Run("only the charter's roster resolves a case", testOperatorsOnlyRosterRules) + t.Run("a ruling is bound to its platform", testOperatorsPlatformBound) + t.Run("Rule is the only path to a resolution", testOperatorsOnlyPath) +} + +// openCase opens a fresh case on f and returns its ID and exchange. +func openCase(t *testing.T, f *fixture, ex ExchangeRef) DisputeID { + t.Helper() + f.anchors.seal(ex, f.cPub, f.rPub) + id, err := f.reg.Open(mkOpening(t, f.cPub, f.cKey, f.rPub, ex)) + if err != nil { + t.Fatalf("Open: %v", err) + } + return id +} + +func ruling(f *fixture, id DisputeID, ex ExchangeRef) Ruling { + return NewEscrowRuling(f.charter.Platform, id, ex, FindingForComplainant, + ActionRefundComplainant, 10, sha256.Sum256([]byte("rationale")), time.Unix(1700000000, 0).UTC()) +} + +func testOperatorsOnlyRosterRules(t *testing.T) { + f := newFixture(t, 2) + ex := ref(0x51) + id := openCase(t, f, ex) + _, witness := genKeyT(t) + _, stranger := genKeyT(t) + + impostors := []struct { + name string + signers []ed25519.PrivateKey + }{ + {"complainant", []ed25519.PrivateKey{f.cKey}}, + {"respondent", []ed25519.PrivateKey{f.rKey}}, + {"both parties together", []ed25519.PrivateKey{f.cKey, f.rKey}}, + {"a witness", []ed25519.PrivateKey{witness}}, + {"a stranger", []ed25519.PrivateKey{stranger}}, + {"parties, witness and stranger", []ed25519.PrivateKey{f.cKey, f.rKey, witness, stranger}}, + {"one adjudicator below threshold", []ed25519.PrivateKey{f.adj1}}, + {"one adjudicator twice", []ed25519.PrivateKey{f.adj1, f.adj1}}, + {"one adjudicator plus a party", []ed25519.PrivateKey{f.adj1, f.cKey}}, + } + for _, imp := range impostors { + r := ruling(f, id, ex) + for _, k := range imp.signers { + r.Sign(k) + } + if err := f.reg.Rule(r); !errors.Is(err, ErrUnauthorized) { + t.Errorf("ruling signed by %s = %v, want ErrUnauthorized: only the operator's roster adjudicates", imp.name, err) + } + } + c, err := f.reg.Case(id) + if err != nil { + t.Fatalf("Case: %v", err) + } + if c.State() != StateOpen { + t.Fatalf("case is %s after refused rulings, want open", c.State()) + } + + // The roster, at threshold, resolves it. + if err := f.reg.Rule(f.signRuling(ruling(f, id, ex), 2)); err != nil { + t.Fatalf("roster ruling: %v", err) + } + if c, _ := f.reg.Case(id); c.State() != StateResolved { + t.Fatalf("case is %s after the roster ruled, want resolved", c.State()) + } +} + +func testOperatorsPlatformBound(t *testing.T) { + f := newFixture(t, 1) + ex := ref(0x52) + id := openCase(t, f, ex) + + // The right roster, the wrong platform. + r := ruling(f, id, ex) + r.Platform = "another-platform" + r.Sign(f.adj1) + if err := f.reg.Rule(r); err == nil { + t.Error("a ruling for another platform resolved a case here") + } + + // Another platform's roster, signing for this platform. + other := newFixture(t, 1) + r = ruling(f, id, ex) + r.Sign(other.adj1) + if err := f.reg.Rule(r); !errors.Is(err, ErrUnauthorized) { + t.Errorf("another platform's adjudicator = %v, want ErrUnauthorized", err) + } + if c, _ := f.reg.Case(id); c.State() != StateOpen { + t.Fatalf("case is %s, want open", c.State()) + } +} + +// registryAPI is every exported method the Registry may ship. A new one is +// a possible second path to a resolution and fails until reviewed. +var registryAPI = map[string]bool{"Open": true, "Rule": true, "Withdraw": true, "Case": true} + +func testOperatorsOnlyPath(t *testing.T) { + typ := reflect.TypeOf(&Registry{}) + got := map[string]bool{} + for i := 0; i < typ.NumMethod(); i++ { + name := typ.Method(i).Name + got[name] = true + if !registryAPI[name] { + t.Errorf("Registry exports %s, not in registryAPI: a new method is a possible way to resolve a case without the operator's roster", name) + } + } + for name := range registryAPI { + if !got[name] { + t.Errorf("registryAPI lists %s, which Registry no longer exports — remove it", name) + } + } + + // Case state is folded in exactly one place, and only a Ruling sets + // StateResolved there. + resolvedSites := 0 + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + fset := token.NewFileSet() + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") { + continue + } + f, err := parser.ParseFile(fset, n, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", n, err) + } + ast.Inspect(f, func(node ast.Node) bool { + as, ok := node.(*ast.AssignStmt) + if !ok { + return true + } + for _, rhs := range as.Rhs { + if id, ok := rhs.(*ast.Ident); ok && id.Name == "StateResolved" { + resolvedSites++ + } + } + return true + }) + } + if resolvedSites != 1 { + t.Errorf("StateResolved is assigned at %d sites, want exactly 1 (the Ruling fold in Case)", resolvedSites) + } + + // A Withdrawal is the complainant's alone and carries no finding. + f := newFixture(t, 1) + ex := ref(0x53) + id := openCase(t, f, ex) + w := Withdrawal{Platform: f.charter.Platform, Dispute: id, WithdrawnAt: time.Unix(1700000100, 0).UTC()} + w.Sign(f.adj1) + if err := f.reg.Withdraw(w); !errors.Is(err, ErrUnauthorized) { + t.Errorf("adjudicator-signed withdrawal = %v, want ErrUnauthorized", err) + } + w.Sign(f.cKey) + if err := f.reg.Withdraw(w); err != nil { + t.Fatalf("complainant withdrawal: %v", err) + } + c, _ := f.reg.Case(id) + if _, found := c.Finding(); c.State() != StateWithdrawn || found { + t.Errorf("after withdrawal: state %s, finding present %v; want withdrawn with no finding", c.State(), found) + } +} diff --git a/internal/record/conformance_test.go b/internal/record/conformance_test.go index 4c8fec0..4bad64b 100644 --- a/internal/record/conformance_test.go +++ b/internal/record/conformance_test.go @@ -7,6 +7,7 @@ package record // invariant: TestInvariant__, with any "-" in the ID written "_". import ( + "crypto/ed25519" "go/ast" "go/parser" "go/token" @@ -14,6 +15,7 @@ import ( "path/filepath" "reflect" "regexp" + "strconv" "strings" "testing" "time" @@ -515,3 +517,193 @@ func receiverName(e ast.Expr) string { } return "" } + +// TestInvariant_REC_witness_minimum_UnfederatedLabel binds registry ID +// REC-witness-minimum: "an operator platform with fewer than two independent +// witnesses forces the unfederated label." +// +// - the label is computed, not asserted: WitnessedCheckpoint.StandIn is +// true for zero or one witness, and stays true when the "second witness" +// is the operator itself, the same witness twice, a forged signature, or +// a signature over a different checkpoint; it drops only when two +// distinct, operator-independent witnesses have verifiably countersigned; +// - every member-facing surface that serves a witnessed checkpoint carries +// the label: any served struct with a countersignatures field also has a +// stand_in field, and every place one is built sets it from a StandIn(…) +// call, never a hard-coded true or false. +// +// Named exception: the witness relay's BundleMsg carries countersignatures +// without a label by design — the relay "does not rank, filter, or count +// them" and readers apply StandIn themselves (internal/relay doc; its +// TestFederationDropsTheStandInLabel shows a reader dropping the label once +// two independent witnesses cosign). It is pinned by name so no new surface +// can hide behind it. +func TestInvariant_REC_witness_minimum_UnfederatedLabel(t *testing.T) { + t.Run("the label is computed from verified independent witnesses", testWitnessMinimumMatrix) + t.Run("every served checkpoint carries the computed label", testWitnessMinimumSurfaces) +} + +func testWitnessMinimumMatrix(t *testing.T) { + op := newParty(t) + a, b := newParty(t), newParty(t) + l, _, _ := buildLog(t, op, a, b, 2) + cp := l.Checkpoint(testInstant) + cp.Sign(op.priv) + other := l.Checkpoint(testInstant.Add(time.Hour)) // same head, different checkpoint bytes + other.Sign(op.priv) + + cosign := func(w party, c Checkpoint) Countersignature { + t.Helper() + cs, err := NewWitness(w.priv).Countersign(c, op.pub, nil) + if err != nil { + t.Fatalf("Countersign: %v", err) + } + return cs + } + w1, w2, w3 := newParty(t), newParty(t), newParty(t) + // The operator cannot obtain a countersignature through Witness; forge + // the shape directly to prove StandIn still refuses to count it. + opCosig := Countersignature{Witness: op.pub, Signature: ed25519.Sign(op.priv, witnessPayload(cp))} + forged := cosign(w2, cp) + forged.Signature = append([]byte(nil), forged.Signature...) + forged.Signature[0] ^= 0xFF + + cases := []struct { + name string + cosigs []Countersignature + standIn bool + }{ + {"no witness", nil, true}, + {"one witness", []Countersignature{cosign(w1, cp)}, true}, + {"one witness plus the operator", []Countersignature{cosign(w1, cp), opCosig}, true}, + {"the same witness twice", []Countersignature{cosign(w1, cp), cosign(w1, cp)}, true}, + {"one witness plus a forged signature", []Countersignature{cosign(w1, cp), forged}, true}, + {"one witness plus a cosignature of another checkpoint", []Countersignature{cosign(w1, cp), cosign(w2, other)}, true}, + {"two independent witnesses", []Countersignature{cosign(w1, cp), cosign(w2, cp)}, false}, + {"three independent witnesses", []Countersignature{cosign(w1, cp), cosign(w2, cp), cosign(w3, cp)}, false}, + } + for _, c := range cases { + wc := WitnessedCheckpoint{Checkpoint: cp, Countersignatures: c.cosigs} + if got := wc.StandIn(op.pub); got != c.standIn { + t.Errorf("%s: StandIn = %v, want %v", c.name, got, c.standIn) + } + } +} + +// labelExempt are served types that carry countersignatures without a +// stand_in field, each with the reason. +var labelExempt = map[string]string{ + filepath.Join("internal", "relay") + ".BundleMsg": "non-authoritative relay; readers compute StandIn", +} + +func testWitnessMinimumSurfaces(t *testing.T) { + root := moduleRoot(t) + type file struct { + rel string + f *ast.File + } + var files []file + walkSource(t, root, func(rel string, f *ast.File) { files = append(files, file{rel, f}) }) + + jsonName := func(tag *ast.BasicLit) string { + if tag == nil { + return "" + } + v, _ := strconv.Unquote(tag.Value) + j := reflect.StructTag(v).Get("json") + return strings.Split(j, ",")[0] + } + + // Pass 1: served structs carrying countersignatures, and whether each + // also carries a stand_in bool. + labelled := map[string]bool{} // dir.Type -> has StandIn + exemptSeen := map[string]bool{} + for _, fl := range files { + if strings.HasPrefix(fl.rel, filepath.Join("internal", "record")+string(filepath.Separator)) { + continue // the record layer's own types; StandIn is a method there + } + dir := filepath.Dir(fl.rel) + ast.Inspect(fl.f, func(n ast.Node) bool { + ts, ok := n.(*ast.TypeSpec) + if !ok { + return true + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + return true + } + hasCosigs, hasLabel := false, false + for _, fld := range st.Fields.List { + switch jsonName(fld.Tag) { + case "countersignatures": + hasCosigs = true + case "stand_in": + if id, ok := fld.Type.(*ast.Ident); ok && id.Name == "bool" { + hasLabel = true + } + } + } + if !hasCosigs { + return true + } + key := dir + "." + ts.Name.Name + if _, ok := labelExempt[key]; ok { + exemptSeen[key] = true + return true + } + labelled[key] = hasLabel + if !hasLabel { + t.Errorf("%s: %s serves countersignatures without a stand_in bool: a surface presenting witnessed data must carry the unfederated label", fl.rel, ts.Name.Name) + } + return true + }) + } + for key := range labelExempt { + if !exemptSeen[key] { + t.Errorf("labelExempt lists %s, which no longer serves countersignatures — remove it", key) + } + } + if len(labelled) == 0 { + t.Fatal("found no member-facing type serving countersignatures; the scan is not looking at the surfaces") + } + + // Pass 2: every literal of a labelled type sets StandIn from a StandIn(…) + // call — never a constant. + built := 0 + for _, fl := range files { + dir := filepath.Dir(fl.rel) + ast.Inspect(fl.f, func(n ast.Node) bool { + cl, ok := n.(*ast.CompositeLit) + if !ok { + return true + } + id, ok := cl.Type.(*ast.Ident) + if !ok || !labelled[dir+"."+id.Name] { + return true + } + built++ + computed := false + for _, el := range cl.Elts { + kv, ok := el.(*ast.KeyValueExpr) + if !ok { + continue + } + if k, ok := kv.Key.(*ast.Ident); !ok || k.Name != "StandIn" { + continue + } + if call, ok := kv.Value.(*ast.CallExpr); ok { + if sel, ok := call.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "StandIn" { + computed = true + } + } + } + if !computed { + t.Errorf("%s: a %s is built without StandIn set from a StandIn(…) call: the label must be computed, never asserted", fl.rel, id.Name) + } + return true + }) + } + if built == 0 { + t.Fatal("found no place that builds a labelled checkpoint response; the scan is not looking at the handlers") + } +}