From b3644abc0eddfc60dcbd274e50b8517ed8e371f1 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 9 Aug 2026 01:54:43 +0000 Subject: [PATCH] Enforce durable D&D evidence ranges --- .../dnd/codec/itemoccurrences/codec.go | 8 ++--- .../dnd/codec/itemoccurrences/codec_test.go | 35 +++++++++++++++++++ .../modules/dnd/codec/itemregistry/codec.go | 8 ++--- .../dnd/codec/itemregistry/codec_test.go | 35 +++++++++++++++++++ .../dnd/codec/locationoccurrences/codec.go | 8 ++--- .../codec/locationoccurrences/codec_test.go | 35 +++++++++++++++++++ .../dnd/codec/locationregistry/codec.go | 8 ++--- .../dnd/codec/locationregistry/codec_test.go | 35 +++++++++++++++++++ .../modules/dnd/codec/npcoccurrences/codec.go | 8 ++--- .../dnd/codec/npcoccurrences/codec_test.go | 35 +++++++++++++++++++ .../modules/dnd/codec/npcregistry/codec.go | 8 ++--- .../dnd/codec/npcregistry/codec_test.go | 35 +++++++++++++++++++ .../dnd/codec/sourcerange/sourcerange.go | 24 +++++++++++++ .../dnd/codec/sourcerange/sourcerange_test.go | 31 ++++++++++++++++ .../dnd/items/registry/registry_test.go | 12 +++++++ .../dnd/locations/identity/identity.go | 2 +- .../dnd/locations/identity/identity_test.go | 10 ++++++ .../dnd/locations/registry/registry_test.go | 12 +++++++ .../dnd/npcs/registry/registry_test.go | 12 +++++++ 19 files changed, 330 insertions(+), 31 deletions(-) create mode 100644 internal/modules/dnd/codec/sourcerange/sourcerange.go create mode 100644 internal/modules/dnd/codec/sourcerange/sourcerange_test.go diff --git a/internal/modules/dnd/codec/itemoccurrences/codec.go b/internal/modules/dnd/codec/itemoccurrences/codec.go index ffa2946..20d5b58 100644 --- a/internal/modules/dnd/codec/itemoccurrences/codec.go +++ b/internal/modules/dnd/codec/itemoccurrences/codec.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemoccurrences" ) @@ -120,11 +121,8 @@ func validate(value dnd.ItemOccurrenceList) error { if strings.TrimSpace(ref.SourceID) == "" { return fmt.Errorf("%s.source_id must not be empty", refPrefix) } - if ref.StartUnitID <= 0 { - return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) - } - if ref.EndUnitID <= 0 { - return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + if err := sourcerange.Validate(ref); err != nil { + return fmt.Errorf("%s: %w", refPrefix, err) } } } diff --git a/internal/modules/dnd/codec/itemoccurrences/codec_test.go b/internal/modules/dnd/codec/itemoccurrences/codec_test.go index 213f1ea..a5e5daa 100644 --- a/internal/modules/dnd/codec/itemoccurrences/codec_test.go +++ b/internal/modules/dnd/codec/itemoccurrences/codec_test.go @@ -86,6 +86,41 @@ func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) { } } +func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.ItemOccurrenceList) + want string + }{ + {name: "nonpositive start", mutate: func(value *dnd.ItemOccurrenceList) { value.Occurrences[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.ItemOccurrenceList) { value.Occurrences[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.ItemOccurrenceList) { + value.Occurrences[0].SourceRefs[0].StartUnitID = 2 + value.Occurrences[0].SourceRefs[0].EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := validList() + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCodecRejectsStrictJSONAndApprovedBoundaries(t *testing.T) { validJSON := `{"occurrences":[{"item_id":"ring","name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` tests := []struct { diff --git a/internal/modules/dnd/codec/itemregistry/codec.go b/internal/modules/dnd/codec/itemregistry/codec.go index 05c0b67..933f9d5 100644 --- a/internal/modules/dnd/codec/itemregistry/codec.go +++ b/internal/modules/dnd/codec/itemregistry/codec.go @@ -9,6 +9,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity" ) @@ -100,11 +101,8 @@ func validate(value dnd.ItemRegistry) error { if strings.TrimSpace(ref.SourceID) == "" { return fmt.Errorf("%s.source_id must not be empty", refPrefix) } - if ref.StartUnitID <= 0 { - return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) - } - if ref.EndUnitID <= 0 { - return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + if err := sourcerange.Validate(ref); err != nil { + return fmt.Errorf("%s: %w", refPrefix, err) } } } diff --git a/internal/modules/dnd/codec/itemregistry/codec_test.go b/internal/modules/dnd/codec/itemregistry/codec_test.go index f6b9e0b..88393ac 100644 --- a/internal/modules/dnd/codec/itemregistry/codec_test.go +++ b/internal/modules/dnd/codec/itemregistry/codec_test.go @@ -117,6 +117,41 @@ func TestCodecCandidateDecodeOwnsValues(t *testing.T) { } } +func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.ItemRegistry) + want string + }{ + {name: "nonpositive start", mutate: func(value *dnd.ItemRegistry) { value.Items[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.ItemRegistry) { value.Items[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.ItemRegistry) { + value.Items[0].SourceRefs[0].StartUnitID = 2 + value.Items[0].SourceRefs[0].EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := validRegistry() + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCodecSchemaAndMetadataAreDefensive(t *testing.T) { codec := New() first := codec.Schema() diff --git a/internal/modules/dnd/codec/locationoccurrences/codec.go b/internal/modules/dnd/codec/locationoccurrences/codec.go index 1ca7be2..6b6cf81 100644 --- a/internal/modules/dnd/codec/locationoccurrences/codec.go +++ b/internal/modules/dnd/codec/locationoccurrences/codec.go @@ -9,6 +9,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" ) @@ -103,11 +104,8 @@ func validate(value dnd.LocationOccurrenceList) error { if strings.TrimSpace(ref.SourceID) == "" { return fmt.Errorf("%s.source_id must not be empty", refPrefix) } - if ref.StartUnitID <= 0 { - return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) - } - if ref.EndUnitID <= 0 { - return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + if err := sourcerange.Validate(ref); err != nil { + return fmt.Errorf("%s: %w", refPrefix, err) } } } diff --git a/internal/modules/dnd/codec/locationoccurrences/codec_test.go b/internal/modules/dnd/codec/locationoccurrences/codec_test.go index c3279b3..dfa8852 100644 --- a/internal/modules/dnd/codec/locationoccurrences/codec_test.go +++ b/internal/modules/dnd/codec/locationoccurrences/codec_test.go @@ -140,6 +140,41 @@ func TestCodecCandidateDecodeOwnsValues(t *testing.T) { } } +func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.LocationOccurrenceList) + want string + }{ + {name: "nonpositive start", mutate: func(value *dnd.LocationOccurrenceList) { value.Occurrences[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.LocationOccurrenceList) { value.Occurrences[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.LocationOccurrenceList) { + value.Occurrences[0].SourceRefs[0].StartUnitID = 2 + value.Occurrences[0].SourceRefs[0].EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := validList() + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) { valid := `{"occurrences":[{"location_id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","kind":"visited","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` for _, test := range []struct{ name, raw, want string }{ diff --git a/internal/modules/dnd/codec/locationregistry/codec.go b/internal/modules/dnd/codec/locationregistry/codec.go index 15c28fa..ebc4eea 100644 --- a/internal/modules/dnd/codec/locationregistry/codec.go +++ b/internal/modules/dnd/codec/locationregistry/codec.go @@ -9,6 +9,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" ) @@ -100,11 +101,8 @@ func validate(value dnd.LocationRegistry) error { if strings.TrimSpace(ref.SourceID) == "" { return fmt.Errorf("%s.source_id must not be empty", refPrefix) } - if ref.StartUnitID <= 0 { - return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) - } - if ref.EndUnitID <= 0 { - return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + if err := sourcerange.Validate(ref); err != nil { + return fmt.Errorf("%s: %w", refPrefix, err) } } } diff --git a/internal/modules/dnd/codec/locationregistry/codec_test.go b/internal/modules/dnd/codec/locationregistry/codec_test.go index 23ec8f9..bdfe2fe 100644 --- a/internal/modules/dnd/codec/locationregistry/codec_test.go +++ b/internal/modules/dnd/codec/locationregistry/codec_test.go @@ -139,6 +139,41 @@ func TestCodecCandidateDecodeOwnsValues(t *testing.T) { } } +func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.LocationRegistry) + want string + }{ + {name: "nonpositive start", mutate: func(value *dnd.LocationRegistry) { value.Locations[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.LocationRegistry) { value.Locations[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.LocationRegistry) { + value.Locations[0].SourceRefs[0].StartUnitID = 2 + value.Locations[0].SourceRefs[0].EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := validList() + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCodecRejectsStructuralJSONBeforeSemanticApproval(t *testing.T) { valid := `{"locations":[{"id":"location:sha256:0000000000000000000000000000000000000000000000000000000000000000","name":"The Tavern","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` for _, test := range []struct{ name, raw, want string }{ diff --git a/internal/modules/dnd/codec/npcoccurrences/codec.go b/internal/modules/dnd/codec/npcoccurrences/codec.go index c02a0bd..76bbb87 100644 --- a/internal/modules/dnd/codec/npcoccurrences/codec.go +++ b/internal/modules/dnd/codec/npcoccurrences/codec.go @@ -9,6 +9,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" ) const ( @@ -102,11 +103,8 @@ func validate(value dnd.NPCOccurrenceList) error { if strings.TrimSpace(ref.SourceID) == "" { return fmt.Errorf("%s.source_id must not be empty", refPrefix) } - if ref.StartUnitID <= 0 { - return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) - } - if ref.EndUnitID <= 0 { - return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + if err := sourcerange.Validate(ref); err != nil { + return fmt.Errorf("%s: %w", refPrefix, err) } } } diff --git a/internal/modules/dnd/codec/npcoccurrences/codec_test.go b/internal/modules/dnd/codec/npcoccurrences/codec_test.go index 917e0ae..0755f2c 100644 --- a/internal/modules/dnd/codec/npcoccurrences/codec_test.go +++ b/internal/modules/dnd/codec/npcoccurrences/codec_test.go @@ -139,6 +139,41 @@ func TestCodecCandidateDecodeOwnsValues(t *testing.T) { } } +func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.NPCOccurrenceList) + want string + }{ + {name: "nonpositive start", mutate: func(value *dnd.NPCOccurrenceList) { value.Occurrences[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.NPCOccurrenceList) { value.Occurrences[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.NPCOccurrenceList) { + value.Occurrences[0].SourceRefs[0].StartUnitID = 2 + value.Occurrences[0].SourceRefs[0].EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := validList() + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCodecStrictlyRejectsMalformedUnknownAndTrailingJSON(t *testing.T) { validJSON := `{"occurrences":[{"npc_id":"npc:test-mira","name":"Mira Thorn","kind":"dialogue","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}` for _, test := range []struct{ name, raw, want string }{ diff --git a/internal/modules/dnd/codec/npcregistry/codec.go b/internal/modules/dnd/codec/npcregistry/codec.go index 5202e49..a43ee3f 100644 --- a/internal/modules/dnd/codec/npcregistry/codec.go +++ b/internal/modules/dnd/codec/npcregistry/codec.go @@ -8,6 +8,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/sourcerange" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" ) @@ -99,11 +100,8 @@ func validate(value dnd.NPCRegistry) error { if strings.TrimSpace(ref.SourceID) == "" { return fmt.Errorf("%s.source_id must not be empty", refPrefix) } - if ref.StartUnitID <= 0 { - return fmt.Errorf("%s.start_unit_id must be positive", refPrefix) - } - if ref.EndUnitID <= 0 { - return fmt.Errorf("%s.end_unit_id must be positive", refPrefix) + if err := sourcerange.Validate(ref); err != nil { + return fmt.Errorf("%s: %w", refPrefix, err) } } } diff --git a/internal/modules/dnd/codec/npcregistry/codec_test.go b/internal/modules/dnd/codec/npcregistry/codec_test.go index 1ea0a44..973f3f4 100644 --- a/internal/modules/dnd/codec/npcregistry/codec_test.go +++ b/internal/modules/dnd/codec/npcregistry/codec_test.go @@ -112,6 +112,41 @@ func TestCodecCandidatePreservesInvalidTypedValues(t *testing.T) { } } +func TestCodecRejectsInvalidDurableRangesWhileCandidatesPreserveThem(t *testing.T) { + codec := New() + for _, test := range []struct { + name string + mutate func(*dnd.NPCRegistry) + want string + }{ + {name: "nonpositive start", mutate: func(value *dnd.NPCRegistry) { value.NPCs[0].SourceRefs[0].StartUnitID = 0 }, want: "start_unit_id"}, + {name: "nonpositive end", mutate: func(value *dnd.NPCRegistry) { value.NPCs[0].SourceRefs[0].EndUnitID = 0 }, want: "end_unit_id"}, + {name: "reversed", mutate: func(value *dnd.NPCRegistry) { + value.NPCs[0].SourceRefs[0].StartUnitID = 2 + value.NPCs[0].SourceRefs[0].EndUnitID = 1 + }, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + candidate := validList() + test.mutate(&candidate) + content, err := codec.EncodeCandidate(candidate) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + decoded, err := codec.DecodeCandidate(content) + if err != nil || !reflect.DeepEqual(decoded, candidate) { + t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", decoded, err, candidate) + } + if _, err := codec.Encode(candidate); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Encode() error = %v, want %q", err, test.want) + } + if _, err := codec.Decode(content); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("Decode() error = %v, want %q", err, test.want) + } + }) + } +} + func TestCodecCandidateDecodeOwnsValues(t *testing.T) { codec := New() input := validList() diff --git a/internal/modules/dnd/codec/sourcerange/sourcerange.go b/internal/modules/dnd/codec/sourcerange/sourcerange.go new file mode 100644 index 0000000..17718d7 --- /dev/null +++ b/internal/modules/dnd/codec/sourcerange/sourcerange.go @@ -0,0 +1,24 @@ +// Package sourcerange validates durable D&D source-reference ranges. +package sourcerange + +import ( + "fmt" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" +) + +// Validate checks the source-independent numeric constraints of a durable +// source reference. Source membership and extraction-chunk coverage require +// source context and remain the responsibility of source-aware validators. +func Validate(ref source.SourceRef) error { + if ref.StartUnitID <= 0 { + return fmt.Errorf("start_unit_id must be positive") + } + if ref.EndUnitID <= 0 { + return fmt.Errorf("end_unit_id must be positive") + } + if ref.StartUnitID > ref.EndUnitID { + return fmt.Errorf("start_unit_id must not exceed end_unit_id") + } + return nil +} diff --git a/internal/modules/dnd/codec/sourcerange/sourcerange_test.go b/internal/modules/dnd/codec/sourcerange/sourcerange_test.go new file mode 100644 index 0000000..5d38482 --- /dev/null +++ b/internal/modules/dnd/codec/sourcerange/sourcerange_test.go @@ -0,0 +1,31 @@ +package sourcerange + +import ( + "strings" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/source" +) + +func TestValidateChecksOnlyDurableNumericRange(t *testing.T) { + for _, test := range []struct { + name string + ref source.SourceRef + want string + }{ + {name: "valid without source ID", ref: source.SourceRef{StartUnitID: 1, EndUnitID: 2}}, + {name: "nonpositive start", ref: source.SourceRef{StartUnitID: 0, EndUnitID: 1}, want: "start_unit_id"}, + {name: "nonpositive end", ref: source.SourceRef{StartUnitID: 1, EndUnitID: 0}, want: "end_unit_id"}, + {name: "reversed", ref: source.SourceRef{StartUnitID: 2, EndUnitID: 1}, want: "must not exceed"}, + } { + t.Run(test.name, func(t *testing.T) { + err := Validate(test.ref) + if test.want == "" && err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + if test.want != "" && (err == nil || !strings.Contains(err.Error(), test.want)) { + t.Fatalf("Validate() error = %v, want %q", err, test.want) + } + }) + } +} diff --git a/internal/modules/dnd/items/registry/registry_test.go b/internal/modules/dnd/items/registry/registry_test.go index a1ee048..0937306 100644 --- a/internal/modules/dnd/items/registry/registry_test.go +++ b/internal/modules/dnd/items/registry/registry_test.go @@ -87,6 +87,18 @@ func TestResolveRejectsInvalidIdentityAndMalformedInput(t *testing.T) { } } +func TestResolveRejectsReversedEvidenceInDirectRegistryReference(t *testing.T) { + value := fixture() + value.Items[0].SourceRefs[0] = source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 1} + content, err := itemcodec.New().EncodeCandidate(value) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + if _, err := Resolve(referenceSet(contracts.ReferenceItem{MediaType: itemcodec.MediaType, Content: content})); err == nil || !strings.Contains(err.Error(), "invalid approved") { + t.Fatalf("Resolve() error = %v, want direct-reference rejection", err) + } +} + func TestResolverKeepsConstructionAndOperationReferencesIndependent(t *testing.T) { resolver, err := NewResolver(contracts.ReferenceSet{}) if err != nil || resolver.Seeded().Bound() { diff --git a/internal/modules/dnd/locations/identity/identity.go b/internal/modules/dnd/locations/identity/identity.go index 14c3e72..83aa4c6 100644 --- a/internal/modules/dnd/locations/identity/identity.go +++ b/internal/modules/dnd/locations/identity/identity.go @@ -168,5 +168,5 @@ func canonicalReferences(refs []source.SourceRef) []source.SourceRef { } func validIdentityReference(ref source.SourceRef) bool { - return strings.TrimSpace(ref.SourceID) == ref.SourceID && ref.SourceID != "" && ref.StartUnitID > 0 && ref.EndUnitID > 0 + return strings.TrimSpace(ref.SourceID) == ref.SourceID && ref.SourceID != "" && ref.StartUnitID > 0 && ref.EndUnitID > 0 && ref.StartUnitID <= ref.EndUnitID } diff --git a/internal/modules/dnd/locations/identity/identity_test.go b/internal/modules/dnd/locations/identity/identity_test.go index ebff1f4..61b155e 100644 --- a/internal/modules/dnd/locations/identity/identity_test.go +++ b/internal/modules/dnd/locations/identity/identity_test.go @@ -75,6 +75,16 @@ func TestDeriveIDRejectsMissingIdentityComponents(t *testing.T) { } } +func TestDeriveIDRejectsReversedOnlyEvidence(t *testing.T) { + if got := DeriveID("The Tavern", []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 1}}); got != "" { + t.Fatalf("DeriveID() = %q, want empty ID", got) + } + valid := source.SourceRef{SourceID: "session", StartUnitID: 3, EndUnitID: 3} + if got, want := DeriveID("The Tavern", []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 1}, valid}), DeriveID("The Tavern", []source.SourceRef{valid}); got != want { + t.Fatalf("DeriveID() = %q, want %q after filtering reversed evidence", got, want) + } +} + func TestIsValidID(t *testing.T) { valid := DeriveID("The Tavern", []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}) if !IsValidID(valid) || !ValidID(valid) { diff --git a/internal/modules/dnd/locations/registry/registry_test.go b/internal/modules/dnd/locations/registry/registry_test.go index a8a0a74..5d48b33 100644 --- a/internal/modules/dnd/locations/registry/registry_test.go +++ b/internal/modules/dnd/locations/registry/registry_test.go @@ -88,6 +88,18 @@ func TestResolveRejectsInvalidReferenceInputs(t *testing.T) { } } +func TestResolveRejectsReversedEvidenceInDirectRegistryReference(t *testing.T) { + value := registryFixture() + value.Locations[0].SourceRefs[0] = source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 1} + content, err := locationcodec.New().EncodeCandidate(value) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + if _, err := Resolve(referenceSet(item(content))); err == nil || !strings.Contains(err.Error(), "invalid approved") { + t.Fatalf("Resolve() error = %v, want direct-reference rejection", err) + } +} + func TestResolverHandlesConstructionAndOperationReferences(t *testing.T) { placeholder, err := NewResolver(referenceSet()) if err != nil || placeholder.Seeded().Bound() { diff --git a/internal/modules/dnd/npcs/registry/registry_test.go b/internal/modules/dnd/npcs/registry/registry_test.go index e33848e..06ff5be 100644 --- a/internal/modules/dnd/npcs/registry/registry_test.go +++ b/internal/modules/dnd/npcs/registry/registry_test.go @@ -128,6 +128,18 @@ func TestResolveRejectsMalformedOrUnsupportedRegistryInput(t *testing.T) { } } +func TestResolveRejectsReversedEvidenceInDirectRegistryReference(t *testing.T) { + value := registryFixture() + value.NPCs[0].SourceRefs[0] = source.SourceRef{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 1} + content, err := npccodec.New().EncodeCandidate(value) + if err != nil { + t.Fatalf("EncodeCandidate() error = %v", err) + } + if _, err := Resolve(referenceSet(contracts.ReferenceItem{MediaType: npccodec.MediaType, Content: content})); err == nil || !strings.Contains(err.Error(), "invalid approved") { + t.Fatalf("Resolve() error = %v, want direct-reference rejection", err) + } +} + func TestResolverReusesEquivalentCanonicalRegistries(t *testing.T) { set := listReferenceSet(t, registryFixture()) resolver, err := NewResolver(contracts.ReferenceSet{})