From cd82cd7ce7a4f9e883b93debec48d9f8a84ec926 Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Tue, 19 Aug 2025 16:31:56 -0300 Subject: [PATCH] event typechecker. --- go.mod | 4 +- go.sum | 4 + pointers.go | 32 ++- schema/schema.go | 175 +++++++++++++ schema/schema.yaml | 259 +++++++++++++++++++ schema/schema_test.go | 584 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1045 insertions(+), 13 deletions(-) create mode 100644 schema/schema.go create mode 100644 schema/schema.yaml create mode 100644 schema/schema_test.go diff --git a/go.mod b/go.mod index 5202a7f..81b4ab7 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/puzpuzpuz/xsync/v3 v3.5.1 github.com/rs/cors v1.11.1 github.com/rs/zerolog v1.33.0 + github.com/segmentio/encoding v0.5.3 github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/tyler-smith/go-bip32 v1.0.0 @@ -38,6 +39,7 @@ require ( golang.org/x/net v0.37.0 golang.org/x/sync v0.12.0 golang.org/x/text v0.23.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -82,6 +84,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 // indirect + github.com/segmentio/asm v1.1.3 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect @@ -89,5 +92,4 @@ require ( go.opencensus.io v0.24.0 // indirect golang.org/x/sys v0.34.0 // indirect google.golang.org/protobuf v1.36.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1a1fdb9..7f0997d 100644 --- a/go.sum +++ b/go.sum @@ -251,6 +251,10 @@ github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWR github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38 h1:D0vL7YNisV2yqE55+q0lFuGse6U8lxlg7fYTctlT5Gc= github.com/savsgio/gotils v0.0.0-20240704082632-aef3928b8a38/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= diff --git a/pointers.go b/pointers.go index 20a7510..33f39a1 100644 --- a/pointers.go +++ b/pointers.go @@ -122,9 +122,24 @@ type EntityPointer struct { // EntityPointerFromTag creates an EntityPointer from an "a" tag (but it doesn't check if the tag is really "a", it could be anything). func EntityPointerFromTag(refTag Tag) (EntityPointer, error) { - spl := strings.SplitN(refTag[1], ":", 3) + pointer, err := ParseAddrString(refTag[1]) + if err != nil { + return EntityPointer{}, fmt.Errorf("invalid addr '%s': %w", refTag[1], err) + } + + if len(refTag) > 2 { + if relay := (refTag)[2]; IsValidRelayURL(relay) { + pointer.Relays = []string{relay} + } + } + + return pointer, nil +} + +func ParseAddrString(addr string) (EntityPointer, error) { + spl := strings.SplitN(addr, ":", 3) if len(spl) != 3 { - return EntityPointer{}, fmt.Errorf("invalid addr ref '%s'", refTag[1]) + return EntityPointer{}, fmt.Errorf("invalid splits") } pk, err := PubKeyFromHex(spl[1]) @@ -134,21 +149,14 @@ func EntityPointerFromTag(refTag Tag) (EntityPointer, error) { kind, err := strconv.Atoi(spl[0]) if err != nil || kind > (1<<16) { - return EntityPointer{}, fmt.Errorf("invalid addr kind '%s'", spl[0]) + return EntityPointer{}, fmt.Errorf("invalid kind") } - pointer := EntityPointer{ + return EntityPointer{ Kind: Kind(kind), PublicKey: pk, Identifier: spl[2], - } - if len(refTag) > 2 { - if relay := (refTag)[2]; IsValidRelayURL(relay) { - pointer.Relays = []string{relay} - } - } - - return pointer, nil + }, nil } // MatchesEvent checks if the pointer matches an event. diff --git a/schema/schema.go b/schema/schema.go new file mode 100644 index 0000000..ef8757c --- /dev/null +++ b/schema/schema.go @@ -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 +} diff --git a/schema/schema.yaml b/schema/schema.yaml new file mode 100644 index 0000000..46f66b0 --- /dev/null +++ b/schema/schema.yaml @@ -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 diff --git a/schema/schema_test.go b/schema/schema_test.go new file mode 100644 index 0000000..52c5845 --- /dev/null +++ b/schema/schema_test.go @@ -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) + }) + } +}