event typechecker.

This commit is contained in:
fiatjaf
2025-08-19 16:31:56 -03:00
parent c8d5aa703f
commit cd82cd7ce7
6 changed files with 1045 additions and 13 deletions

175
schema/schema.go Normal file
View File

@@ -0,0 +1,175 @@
package schema
import (
_ "embed"
"encoding/hex"
"fmt"
"net/url"
"slices"
"strconv"
"strings"
"unsafe"
"fiatjaf.com/nostr"
"github.com/segmentio/encoding/json"
"gopkg.in/yaml.v3"
)
//go:embed schema.yaml
var schemaFile []byte
type Schema map[string]KindSchema
type KindSchema struct {
Content string `yaml:"content"`
Tags []tagSpec `yaml:"tags"`
}
type tagSpec struct {
Name string `yaml:"name"`
Prefix string `yaml:"prefix"`
Next *nextSpec `yaml:"next"`
}
type nextSpec struct {
Type string `yaml:"type"`
Required bool `yaml:"required"`
Either []string `yaml:"either"`
Next *nextSpec `yaml:"next"`
Variadic bool `yaml:"variadic"`
}
type Validator struct {
Schema Schema
FailOnUnknown bool
}
func NewValidator(schemaData string) Validator {
schema := make(Schema)
if err := yaml.Unmarshal([]byte(schemaData), &schema); err != nil {
panic(fmt.Errorf("failed to parse schema.yaml: %w", err))
}
return Validator{Schema: schema}
}
var (
ErrUnknownContent = fmt.Errorf("unknown content")
ErrUnknownKind = fmt.Errorf("unknown kind")
ErrInvalidContentJson = fmt.Errorf("invalid content json")
ErrEmptyTag = fmt.Errorf("empty tag")
ErrUnknownTagType = fmt.Errorf("unknown tag type")
)
func (v *Validator) ValidateEvent(evt nostr.Event) error {
if sch, ok := v.Schema[strconv.FormatUint(uint64(evt.Kind), 10)]; ok {
switch sch.Content {
case "json":
if !json.Valid(unsafe.Slice(unsafe.StringData(evt.Content), len(evt.Content))) {
return ErrInvalidContentJson
}
case "free":
default:
if v.FailOnUnknown {
return ErrInvalidContentJson
}
}
tags:
for _, tag := range evt.Tags {
if len(tag) == 0 {
return ErrEmptyTag
}
var lastErr error
specs:
for _, tagspec := range sch.Tags {
if tagspec.Name == tag[0] || (tagspec.Prefix != "" && strings.HasPrefix(tag[0], tagspec.Prefix)) {
if tagspec.Next != nil {
if err := v.validateNext(tag, 1, tagspec.Next); err != nil {
lastErr = err
continue specs // see if there is another tagspec that matches this
} else {
continue tags
}
}
} else {
continue specs
}
}
if lastErr != nil {
// there was at least one failure for this tag and no further successes
return lastErr
}
}
}
if v.FailOnUnknown {
return ErrUnknownKind
}
return nil
}
var gitcommitdummydecoder = make([]byte, 20)
func (v *Validator) validateNext(tag nostr.Tag, index int, this *nextSpec) error {
if len(tag) <= index {
if this.Required {
return fmt.Errorf("invalid tag '%s', missing index %d", tag[0], index)
}
return nil
}
switch this.Type {
case "id":
if _, err := nostr.IDFromHex(tag[index]); err != nil {
return fmt.Errorf("invalid id at tag '%s', index %d", tag[0], index)
}
case "pubkey":
if _, err := nostr.PubKeyFromHex(tag[index]); err != nil {
return fmt.Errorf("invalid pubkey at tag '%s', index %d", tag[0], index)
}
case "addr":
if _, err := nostr.ParseAddrString(tag[index]); err != nil {
return fmt.Errorf("invalid addr at tag '%s', index %d", tag[0], index)
}
case "kind":
if _, err := strconv.ParseUint(tag[index], 10, 16); err != nil {
return fmt.Errorf("invalid kind at tag '%s', index %d", tag[0], index)
}
case "relay":
if url, err := url.Parse(tag[index]); err != nil || (url.Scheme != "ws" && url.Scheme != "wss") {
return fmt.Errorf("invalid relay at tag '%s', index %d", tag[0], index)
}
case "constrained":
if !slices.Contains(this.Either, tag[index]) {
return fmt.Errorf("invalid constrained at tag '%s', index %d", tag[0], index)
}
case "gitcommit":
if len(tag[index]) != 40 {
return fmt.Errorf("invalid gitcommit at tag '%s', index %d", tag[0], index)
}
if _, err := hex.Decode(gitcommitdummydecoder, unsafe.Slice(unsafe.StringData(tag[index]), 40)); err != nil {
return fmt.Errorf("invalid gitcommit at tag '%s', index %d", tag[0], index)
}
case "free":
default:
if v.FailOnUnknown {
return ErrUnknownTagType
}
}
if this.Variadic {
// apply this same validation to all further items
if len(tag) >= index {
return v.validateNext(tag, index+1, this)
}
}
if this.Next != nil {
return v.validateNext(tag, index+1, this.Next)
}
return nil
}

259
schema/schema.yaml Normal file
View File

@@ -0,0 +1,259 @@
_profile: &profile
type: pubkey
required: true
next:
type: relay
_event: &event
type: id
required: true
next:
type: relay
next:
type: pubkey
_addr: &addr
type: addr
required: true
next:
type: relay
_kind: &kind
type: kind
required: true
_dtag: &dtag
name: d
next:
type: free
required: true
_atag: &atag
name: a
next: *addr
_ptag: &ptag
name: p
next: *profile
0:
content: json
1:
content: free
tags:
-
name: e
next:
type: id
required: true
next:
type: relay
next:
type: constrained
either:
- reply
- root
next:
type: pubkey
-
name: q
next:
type: id
required: true
next:
type: relay
next:
type: pubkey
-
name: q
next:
type: addr
required: true
next:
type: relay
-
name: p
next: *profile
1111:
content: free
tags:
-
name: A
next: *addr
-
name: a
next: *addr
-
name: E
next: *event
-
name: e
next: *event
-
name: I
next: &external
type: free
required: true
next:
type: url
-
name: i
next: *external
-
name: K
next: *kind
-
name: K
next:
type: free
required: true
-
name: k
next: *kind
-
name: P
next: *profile
-
name: p
next: *profile
10002:
content: empty
tags:
-
name: r
next:
type: relay
required: true
next:
type: constrained
either:
- read
- write
9802:
content: free
tags:
-
name: p
next: *profile
-
name: e
next: *event
-
name: a
next: *addr
30617:
content: empty
tags:
- *dtag
-
name: name
next:
type: free
required: true
-
name: description
next:
type: free
required: true
-
name: web
next:
type: url
required: true
-
name: clone
next:
type: giturl
required: true
-
name: relays
next:
type: relay
variadic: true
-
name: r
next:
type: gitcommit
required: true
next:
type: constrained
either:
- euc
required: true
-
name: maintainers
next:
type: pubkey
variadic: true
30618:
content: empty
tags:
- *dtag
-
prefix: "refs/"
next:
type: gitcommit
required: true
-
name: HEAD
next:
type: free
1617:
content: free
tags:
- *atag
-
name: r
next:
type: gitcommit
required: true
- *ptag
-
name: t
next:
type: constrained
either:
- root
- root-revision
required: true
-
name: commit
next:
type: gitcommit
required: true
-
name: r
next:
type: gitcommit
required: true
-
name: parent-commit
next:
type: gitcommit
required: true
-
name: commit-pgp-sig
next:
type: free
required: true
-
name: committer
next:
type: free
required: true
next:
type: free
required: true
next:
type: free
required: true
next:
type: free
required: true

584
schema/schema_test.go Normal file
View File

@@ -0,0 +1,584 @@
package schema
import (
"testing"
"fiatjaf.com/nostr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewValidator(t *testing.T) {
v := NewValidator(string(schemaFile))
require.NotNil(t, v.Schema)
require.False(t, v.FailOnUnknown) // default value
// test with some known kinds from schema.yaml
_, hasKind0 := v.Schema["0"] // profile metadata
require.True(t, hasKind0)
_, hasKind1 := v.Schema["1"] // text note
require.True(t, hasKind1)
_, hasKind1111 := v.Schema["1111"] // comment
require.True(t, hasKind1111)
}
func TestNewValidatorWithInvalidYAML(t *testing.T) {
require.Panics(t, func() {
NewValidator("invalid yaml content: [[[")
})
}
func TestValidateEvent_BasicSuccess(t *testing.T) {
v := NewValidator(string(schemaFile))
// kind 1 with free content and valid p tag
evt := nostr.Event{
Kind: 1,
Content: "hello world",
Tags: nostr.Tags{
nostr.Tag{"p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"},
},
}
err := v.ValidateEvent(evt)
require.NoError(t, err)
}
func TestValidateEvent_Kind0_JSONContent(t *testing.T) {
v := NewValidator(string(schemaFile))
// valid JSON content
evt := nostr.Event{
Kind: 0,
Content: `{"name":"test","about":"description"}`,
}
err := v.ValidateEvent(evt)
require.NoError(t, err)
// invalid JSON content
evt.Content = "not-json-content"
err = v.ValidateEvent(evt)
require.Error(t, err)
require.Equal(t, ErrInvalidContentJson, err)
}
func TestValidateEvent_UnknownKind(t *testing.T) {
v := NewValidator(string(schemaFile))
evt := nostr.Event{
Kind: nostr.Kind(39999),
Content: "test",
}
// should not fail when FailOnUnknown is false (default)
err := v.ValidateEvent(evt)
require.NoError(t, err)
// should fail when FailOnUnknown is true
v.FailOnUnknown = true
err = v.ValidateEvent(evt)
require.Error(t, err)
require.Equal(t, ErrUnknownKind, err)
}
func TestValidateEvent_EmptyTag(t *testing.T) {
v := NewValidator(string(schemaFile))
evt := nostr.Event{
Kind: 1,
Tags: nostr.Tags{
nostr.Tag{}, // empty tag
},
}
err := v.ValidateEvent(evt)
require.Error(t, err)
require.Equal(t, ErrEmptyTag, err)
}
func TestValidateNext_ID(t *testing.T) {
v := NewValidator(string(schemaFile))
tests := []struct {
name string
tag nostr.Tag
valid bool
}{
{
name: "valid id",
tag: nostr.Tag{"e", "dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c962"},
valid: true,
},
{
name: "invalid id - too short",
tag: nostr.Tag{"e", "dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c9"},
valid: false,
},
{
name: "invalid id - not hex",
tag: nostr.Tag{"e", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next := &nextSpec{Type: "id", Required: true}
err := v.validateNext(tt.tag, 1, next)
if tt.valid {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
}
func TestValidateNext_PubKey(t *testing.T) {
v := NewValidator(string(schemaFile))
tests := []struct {
name string
tag nostr.Tag
valid bool
}{
{
name: "valid pubkey",
tag: nostr.Tag{"p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"},
valid: true,
},
{
name: "invalid pubkey - too short",
tag: nostr.Tag{"p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa45"},
valid: false,
},
{
name: "invalid pubkey - not hex",
tag: nostr.Tag{"p", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next := &nextSpec{Type: "pubkey", Required: true}
err := v.validateNext(tt.tag, 1, next)
if tt.valid {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
}
func TestValidateNext_Relay(t *testing.T) {
v := NewValidator(string(schemaFile))
tests := []struct {
name string
tag nostr.Tag
valid bool
}{
{
name: "valid wss relay",
tag: nostr.Tag{"r", "wss://relay.example.com"},
valid: true,
},
{
name: "valid ws relay",
tag: nostr.Tag{"r", "ws://relay.example.com"},
valid: true,
},
{
name: "invalid relay - http",
tag: nostr.Tag{"r", "http://relay.example.com"},
valid: false,
},
{
name: "invalid relay - https",
tag: nostr.Tag{"r", "https://relay.example.com"},
valid: false,
},
{
name: "invalid relay - malformed url",
tag: nostr.Tag{"r", "not-a-url"},
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next := &nextSpec{Type: "relay", Required: true}
err := v.validateNext(tt.tag, 1, next)
if tt.valid {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
}
func TestValidateNext_Kind(t *testing.T) {
v := NewValidator(string(schemaFile))
tests := []struct {
name string
tag nostr.Tag
valid bool
}{
{
name: "valid kind",
tag: nostr.Tag{"k", "1"},
valid: true,
},
{
name: "valid kind - large number",
tag: nostr.Tag{"k", "30023"},
valid: true,
},
{
name: "invalid kind - not number",
tag: nostr.Tag{"k", "not-a-number"},
valid: false,
},
{
name: "invalid kind - negative",
tag: nostr.Tag{"k", "-1"},
valid: false,
},
{
name: "invalid kind - too large",
tag: nostr.Tag{"k", "99999"},
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next := &nextSpec{Type: "kind", Required: true}
err := v.validateNext(tt.tag, 1, next)
if tt.valid {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
}
func TestValidateNext_Constrained(t *testing.T) {
v := NewValidator(string(schemaFile))
tests := []struct {
name string
tag nostr.Tag
allowed []string
valid bool
}{
{
name: "valid constrained value",
tag: nostr.Tag{"e", "someid", "somerelay", "reply"},
allowed: []string{"reply", "root"},
valid: true,
},
{
name: "invalid constrained value",
tag: nostr.Tag{"e", "someid", "somerelay", "invalid"},
allowed: []string{"reply", "root"},
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next := &nextSpec{Type: "constrained", Required: true, Either: tt.allowed}
err := v.validateNext(tt.tag, 3, next)
if tt.valid {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
}
func TestValidateNext_GitCommit(t *testing.T) {
v := NewValidator(string(schemaFile))
tests := []struct {
name string
tag nostr.Tag
valid bool
}{
{
name: "valid git commit",
tag: nostr.Tag{"r", "a1b2c3d4e5f6789012345678901234567890abcd"},
valid: true,
},
{
name: "invalid git commit - too short",
tag: nostr.Tag{"r", "a1b2c3d4e5f6789012345678901234567890abc"},
valid: false,
},
{
name: "invalid git commit - too long",
tag: nostr.Tag{"r", "a1b2c3d4e5f6789012345678901234567890abcde"},
valid: false,
},
{
name: "invalid git commit - not hex",
tag: nostr.Tag{"r", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next := &nextSpec{Type: "gitcommit", Required: true}
err := v.validateNext(tt.tag, 1, next)
if tt.valid {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
}
func TestValidateNext_Addr(t *testing.T) {
v := NewValidator(string(schemaFile))
tests := []struct {
name string
tag nostr.Tag
valid bool
}{
{
name: "valid addr",
tag: nostr.Tag{"a", "30023:3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d:test"},
valid: true,
},
{
name: "invalid addr - malformed",
tag: nostr.Tag{"a", "invalid-addr"},
valid: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
next := &nextSpec{Type: "addr", Required: true}
err := v.validateNext(tt.tag, 1, next)
if tt.valid {
require.NoError(t, err)
} else {
require.Error(t, err)
}
})
}
}
func TestValidateNext_Free(t *testing.T) {
v := NewValidator(string(schemaFile))
// free type should accept anything
tag := nostr.Tag{"test", "any value here", "even", "multiple", "values"}
next := &nextSpec{Type: "free", Required: true}
err := v.validateNext(tag, 1, next)
require.NoError(t, err)
}
func TestValidateNext_UnknownType(t *testing.T) {
v := NewValidator(string(schemaFile))
tag := nostr.Tag{"test", "value"}
next := &nextSpec{Type: "unknown-type", Required: true}
// should not fail when FailOnUnknown is false (default)
err := v.validateNext(tag, 1, next)
require.NoError(t, err)
// should fail when FailOnUnknown is true
v.FailOnUnknown = true
err = v.validateNext(tag, 1, next)
require.Error(t, err)
require.Equal(t, ErrUnknownTagType, err)
}
func TestValidateNext_RequiredField(t *testing.T) {
v := NewValidator(string(schemaFile))
// test missing required field
tag := nostr.Tag{"test"} // only name, missing required value
next := &nextSpec{Type: "free", Required: true}
err := v.validateNext(tag, 1, next)
require.Error(t, err)
require.Contains(t, err.Error(), "missing index 1")
// test optional field
next = &nextSpec{Type: "free", Required: false}
err = v.validateNext(tag, 1, next)
require.NoError(t, err)
}
func TestValidateNext_Variadic(t *testing.T) {
v := NewValidator(string(schemaFile))
// test variadic field with multiple values
tag := nostr.Tag{"test", "value1", "value2", "value3"}
next := &nextSpec{Type: "free", Variadic: true}
err := v.validateNext(tag, 1, next)
require.NoError(t, err)
// test variadic field with single value
tag = nostr.Tag{"test", "only-one-value"}
err = v.validateNext(tag, 1, next)
require.NoError(t, err)
// test variadic field with no values (should fail if required)
tag = nostr.Tag{"test"}
next = &nextSpec{Type: "free", Variadic: true, Required: true}
err = v.validateNext(tag, 1, next)
require.Error(t, err)
}
func TestValidateEvent_Kind10002(t *testing.T) {
v := NewValidator(string(schemaFile))
// kind 10002 (relay list metadata) with valid r tags
evt := nostr.Event{
Kind: 10002,
Content: "", // should be empty
Tags: nostr.Tags{
nostr.Tag{"r", "wss://relay1.example.com", "read"},
nostr.Tag{"r", "wss://relay2.example.com", "write"},
},
}
err := v.ValidateEvent(evt)
require.NoError(t, err)
// test with invalid relay marker
evt.Tags = nostr.Tags{
nostr.Tag{"r", "wss://relay1.example.com", "invalid"},
}
err = v.ValidateEvent(evt)
require.Error(t, err)
// test with missing required r tags
evt.Tags = nostr.Tags{} // no r tags at all
err = v.ValidateEvent(evt)
require.NoError(t, err) // should pass as tags are not required by schema
}
func TestValidateEvent_Kind1_ETag(t *testing.T) {
v := NewValidator(string(schemaFile))
// kind 1 with e tag (reply/root marker)
evt := nostr.Event{
Kind: 1,
Content: "this is a reply",
Tags: nostr.Tags{
nostr.Tag{"e", "dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c962", "wss://relay.example.com", "reply", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"},
},
}
err := v.ValidateEvent(evt)
require.NoError(t, err)
// test with invalid marker
evt.Tags = nostr.Tags{
nostr.Tag{"e", "dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c962", "wss://relay.example.com", "invalid"},
}
err = v.ValidateEvent(evt)
require.Error(t, err)
}
func TestValidateEvent_Kind30617_RepositoryAnnouncement(t *testing.T) {
v := NewValidator(string(schemaFile))
// kind 30617 (repository announcement) with required tags
evt := nostr.Event{
Kind: 30617,
Content: "", // should be empty
Tags: nostr.Tags{
nostr.Tag{"d", "my-repo"},
nostr.Tag{"name", "My Repository"},
nostr.Tag{"description", "A test repository"},
nostr.Tag{"web", "https://github.com/user/repo"},
nostr.Tag{"clone", "https://github.com/user/repo.git"},
nostr.Tag{"relays", "wss://relay1.example.com", "wss://relay2.example.com"},
nostr.Tag{"r", "a1b2c3d4e5f6789012345678901234567890abcd", "euc"},
nostr.Tag{"maintainers", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"},
},
}
err := v.ValidateEvent(evt)
require.NoError(t, err)
}
func TestValidateEvent_MultiplePossibleTagSpecs(t *testing.T) {
v := NewValidator(string(schemaFile))
// test event with tags that could match multiple specs
// kind 1 has both "e" and "q" tags that can take different forms
evt := nostr.Event{
Kind: 1,
Content: "test content",
Tags: nostr.Tags{
// this should match the q tag with addr format
nostr.Tag{"q", "30023:3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d:test", "wss://relay.example.com"},
},
}
err := v.ValidateEvent(evt)
require.NoError(t, err)
// test q tag with id format (alternative spec)
evt.Tags = nostr.Tags{
nostr.Tag{"q", "dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c962", "wss://relay.example.com", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"},
}
err = v.ValidateEvent(evt)
require.NoError(t, err)
}
func TestSchema_ErrorMessages(t *testing.T) {
v := NewValidator(string(schemaFile))
tests := []struct {
name string
event nostr.Event
expError error
}{
{
name: "empty tag error",
event: nostr.Event{
Kind: 1,
Tags: nostr.Tags{nostr.Tag{}},
},
expError: ErrEmptyTag,
},
{
name: "unknown kind error when FailOnUnknown is true",
event: nostr.Event{
Kind: nostr.Kind(39999),
},
expError: ErrUnknownKind,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.expError == ErrUnknownKind {
v.FailOnUnknown = true
defer func() { v.FailOnUnknown = false }()
}
err := v.ValidateEvent(tt.event)
require.Error(t, err)
assert.Equal(t, tt.expError, err)
})
}
}