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
62 changes: 62 additions & 0 deletions conformance/byteslengen/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Command byteslengen emits bytes_max_len.json: per message, the JSON field names of
// every bytes field declaring (buf.validate.field).bytes.max_len, with the byte limit.
//
// It exists because protoschema-plugins renders that rule as a maxLength on the base64
// string that counts the padding characters, which is not exact: 32768 bytes and 32769
// bytes both encode to 43692 characters, so a token one byte over the limit passes the
// JSON Schema while Go protovalidate rejects it. merge_schema.py and gen_jsonschema.py
// read this manifest and rewrite the field's pattern to allow at most ceil(4n/3) data
// characters, which is exact, so the generated Pydantic/Zod models, the published
// schemas and the strict validators refuse exactly what the Go server refuses.
//
// Like requiredgen and uniquegen, this is a Go program because Go protovalidate is the
// authoritative view of the rules. Scope comes from conformance.Contract.
package main

import (
"encoding/json"
"os"

protovalidate "buf.build/go/protovalidate"
"google.golang.org/protobuf/reflect/protoreflect"

"github.com/FORA-Protocol/protocol/conformance"
)

func main() {
out := "bytes_max_len.json"
if len(os.Args) > 1 {
out = os.Args[1]
}
limits := map[string]map[string]uint64{}
conformance.EachMessage(func(md protoreflect.MessageDescriptor) {
for i := 0; i < md.Fields().Len(); i++ {
fd := md.Fields().Get(i)
if n := bytesMaxLen(fd); n > 0 {
if limits[string(md.Name())] == nil {
limits[string(md.Name())] = map[string]uint64{}
}
limits[string(md.Name())][string(fd.Name())] = n
}
}
})
b, err := json.MarshalIndent(limits, "", " ")
if err != nil {
panic(err)
}
if err := os.WriteFile(out, append(b, '\n'), 0o644); err != nil {
panic(err)
}
}

// bytesMaxLen returns the bytes.max_len rule of a singular bytes field, or 0.
func bytesMaxLen(fd protoreflect.FieldDescriptor) uint64 {
if fd.Kind() != protoreflect.BytesKind || fd.IsList() {
return 0
}
fr, err := protovalidate.ResolveFieldRules(fd)
if err != nil || fr == nil {
return 0
}
return fr.GetBytes().GetMaxLen()
}
32 changes: 32 additions & 0 deletions conformance/corpus/cases.json

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions conformance/corpusgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"os"
Expand Down Expand Up @@ -681,6 +682,8 @@ func validScalar(fd protoreflect.FieldDescriptor, fr *validate.FieldRules) (prot
}
}
return protoreflect.ValueOfFloat64(0), nil
case protoreflect.BytesKind:
return protoreflect.ValueOfBytes(nil), nil // the contract's bytes rules only bound length from above
case protoreflect.MessageKind:
return protoreflect.Value{}, nil // handled by caller via seed
}
Expand Down Expand Up @@ -709,6 +712,8 @@ func edges(fd protoreflect.FieldDescriptor, fr *validate.FieldRules, sd map[stri
es = append(es, enumEdges(fd, fr.GetEnum())...)
case protoreflect.StringKind:
es = append(es, stringEdges(fd, fr.GetString())...)
case protoreflect.BytesKind:
es = append(es, bytesEdges(fd, fr.GetBytes())...)
case protoreflect.Int64Kind:
if r := fr.GetInt64(); r != nil {
if _, ok := r.GetGreaterThan().(*validate.Int64Rules_Gte); ok {
Expand Down Expand Up @@ -877,6 +882,23 @@ func stringEdges(fd protoreflect.FieldDescriptor, r *validate.StringRules) []edg
return es
}

// bytesEdges emits the length boundary of a bytes field: exactly max_len bytes is a
// positive edge Go must accept, max_len+1 bytes trips bytes.max_len. The bytes are a
// run of one letter, deliberately not a parseable token, so the corpus proves the wire
// size rule alone and never a format rule.
func bytesEdges(fd protoreflect.FieldDescriptor, r *validate.BytesRules) []edge {
var es []edge
if n := r.GetMaxLen(); n > 0 {
fit := bytes.Repeat([]byte{'a'}, int(n))
over := bytes.Repeat([]byte{'a'}, int(n)+1)
es = append(es, edge{label: "max_ok", valid: true,
apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfBytes(fit)) }})
es = append(es, edge{label: "too_long", want: "bytes.max_len",
apply: func(m protoreflect.Message) { m.Set(fd, protoreflect.ValueOfBytes(over)) }})
}
return es
}

// failingBadStringIdxs returns the indices of badStrings the pattern rejects, in order.
// Shared by stringEdges (multi-emit) and listEdges item_pattern (first only), so the two
// call sites stay in lockstep on what "a bad string for this pattern" means.
Expand Down Expand Up @@ -975,6 +997,9 @@ func hasConstraint(fr *validate.FieldRules) bool {
if s := fr.GetString(); s != nil && (s.GetPattern() != "" || s.GetMinLen() > 0 || s.GetMaxLen() > 0) {
return true
}
if b := fr.GetBytes(); b != nil && b.GetMaxLen() > 0 {
return true
}
if i := fr.GetInt64(); i != nil && i.GetGreaterThan() != nil {
return true
}
Expand Down
52 changes: 52 additions & 0 deletions conformance/doc_claim_guard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package conformance

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func TestDocDelegationClaimGuard(t *testing.T) {
script, err := os.ReadFile("../scripts/check-doc-conformance.sh")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name, claims, refusal string
}{
{"jwt_only", "fora_parent fora_advisory", ""},
{"mirror", "fora_max_spend_cents", ""},
{"unknown", "fora_parnet", "registered delegation claim 'fora_parnet' has no matching"},
{"other_message_field", "fora_offer_id", "registered delegation claim 'fora_offer_id' has no matching"},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
for path, content := range map[string]string{
"scripts/check-doc-conformance.sh": string(script),
"proto/fora/v1/fora.proto": "message Delegation {\n int64 max_spend_cents = 1;\n}\n" +
"message TransactionItem {\n string offer_id = 1;\n}\n",
"website/src/content/docs/protocol/authentication.mdx": tc.claims,
"website/src/content/docs/components/transaction-log/event-types.mdx": "",
"docs/placeholder.md": "",
} {
target := filepath.Join(root, path)
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
out, err := exec.Command("bash", filepath.Join(root, "scripts/check-doc-conformance.sh")).CombinedOutput()
if tc.refusal == "" {
if err != nil {
t.Fatalf("valid claim rejected: %v\n%s", err, out)
}
} else if err == nil || !strings.Contains(string(out), tc.refusal) {
t.Fatalf("expected refusal %q, got %v\n%s", tc.refusal, err, out)
}
})
}
}
153 changes: 153 additions & 0 deletions conformance/docdelegation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package conformance

import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"

"github.com/FORA-Protocol/protocol/sdk/go/helpers"
)

// Documented delegation tokens must verify.
//
// A page that shows a delegation token shows a real one, taken from the shared
// chain cases (sdk/go/helpers/testdata/delegation-chains.json), and names the
// case on the line above the fence: {/* delegation-chain: <case name> */}. The
// fenced block is either a proto-JSON request whose requester.delegation.token
// carries the token base64-encoded, or the bare compact JWS. This test finds
// every such block, checks the token is byte-identical to the named case, and
// verifies it with helpers.VerifyDelegation under the case's trust input,
// audience, holder thumbprint and clock, expecting the case's declared outcome.
// A proto-JSON block that carries a real-looking token without a marker fails
// too, so no page can show a token nothing verifies.

var chainMarkedFenceRe = regexp.MustCompile("(?s)(?:\\{/\\* delegation-chain: ([a-z0-9_]+) \\*/\\}\\s*\\n)?(?:\\{/\\* fora-validate: [A-Za-z0-9]+ \\*/\\}\\s*\\n)?```[a-zA-Z0-9]*[ \t]*\\n(.*?)```")

type docChainCase struct {
Name string `json:"name"`
Token string `json:"token"`
Trusted []struct {
Identity string `json:"identity"`
KeyX string `json:"key_x"`
} `json:"trusted"`
Audience string `json:"audience"`
Holder string `json:"holder"`
Now string `json:"now"`
Expect struct {
Valid bool `json:"valid"`
Scopes []string `json:"scopes"`
ExpiresAt string `json:"expires_at"`
} `json:"expect"`
}

func loadDocChainCases(t *testing.T) map[string]docChainCase {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "sdk", "go", "helpers", "testdata", "delegation-chains.json"))
if err != nil {
t.Fatal(err)
}
var doc struct {
Cases []docChainCase `json:"cases"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
out := map[string]docChainCase{}
for _, c := range doc.Cases {
out[c.Name] = c
}
return out
}

// documentedToken returns the token a fenced block documents: the base64
// (standard or URL, padded or not) requester.delegation.token of a proto-JSON
// request, or the block itself when it is one bare compact JWS.
func documentedToken(body string) (token string, found bool) {
trimmed := strings.TrimSpace(body)
if strings.Count(trimmed, ".") == 2 && !strings.ContainsAny(trimmed, " \n{}") {
return trimmed, true
}
var req struct {
Requester struct {
Delegation struct {
Token string `json:"token"`
} `json:"delegation"`
} `json:"requester"`
}
if err := json.Unmarshal([]byte(trimmed), &req); err != nil || req.Requester.Delegation.Token == "" {
return "", false
}
enc := req.Requester.Delegation.Token
for _, e := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if b, err := e.DecodeString(enc); err == nil {
return string(b), true
}
}
return enc, true
}

func TestDocDelegationTokensVerify(t *testing.T) {
cases := loadDocChainCases(t)
verified := 0
walkDocs(t, func(path, content string) {
for _, m := range chainMarkedFenceRe.FindAllStringSubmatch(content, -1) {
name, body := m[1], m[2]
token, found := documentedToken(body)
if !found {
continue
}
if name == "" {
if isPlaceholder(token) {
continue
}
t.Errorf("%s: a documented delegation token has no {/* delegation-chain: <case> */} marker, so nothing verifies it", filepath.Base(path))
continue
}
c, ok := cases[name]
if !ok {
t.Errorf("%s: delegation-chain %q is not a case in delegation-chains.json", filepath.Base(path), name)
continue
}
if token != c.Token {
t.Errorf("%s: the documented token differs from case %q", filepath.Base(path), name)
continue
}
in := helpers.DelegationInput{Token: []byte(token), Audience: c.Audience, Holder: c.Holder}
var err error
if in.Now, err = time.Parse(time.RFC3339, c.Now); err != nil {
t.Fatal(err)
}
for _, tr := range c.Trusted {
x, err := base64.RawURLEncoding.DecodeString(tr.KeyX)
if err != nil {
t.Fatal(err)
}
in.TrustedIssuers = append(in.TrustedIssuers, helpers.TrustedIssuer{Identity: tr.Identity, Key: ed25519.PublicKey(x)})
}
got, err := helpers.VerifyDelegation(in)
if !c.Expect.Valid {
t.Errorf("%s: case %q is a refusal case; a page documents only chains that verify", filepath.Base(path), name)
continue
}
if err != nil {
t.Errorf("%s: documented token %q does not verify: %v", filepath.Base(path), name, err)
continue
}
if strings.Join(got.Scopes, " ") != strings.Join(c.Expect.Scopes, " ") || got.ExpiresAt.UTC().Format(time.RFC3339) != c.Expect.ExpiresAt {
t.Errorf("%s: documented token %q verified to %+v, case declares %+v", filepath.Base(path), name, got, c.Expect)
continue
}
verified++
}
})
if verified == 0 {
t.Fatal("no documented delegation token was verified; the docs must show at least one real chain")
}
t.Logf("documented delegation tokens verified=%d", verified)
}
Loading
Loading