diff --git a/internal/tools/foldkey_internal_test.go b/internal/tools/foldkey_internal_test.go new file mode 100644 index 0000000..da7d057 --- /dev/null +++ b/internal/tools/foldkey_internal_test.go @@ -0,0 +1,42 @@ +package tools + +import ( + "fmt" + "strings" + "testing" + "time" +) + +// foldKey must agree with strings.EqualFold, which is how encoding/json matches. +func TestFoldKeyAgreesWithEqualFold(t *testing.T) { + pairs := [][2]string{{"branch", "Branch"}, {"sha", "ſha"}, {"k", "K"}, {"ss", "ß"}, {"a", "b"}, {"Σ", "ς"}} + for _, p := range pairs { + if got, want := foldKey(p[0]) == foldKey(p[1]), strings.EqualFold(p[0], p[1]); got != want { + t.Errorf("%q vs %q: foldKey equal=%v, EqualFold=%v", p[0], p[1], got, want) + } + } +} + +// The duplicate scan is linear in the number of keys: 90k keys (about the +// 1 MiB body cap) must scan well under a second. +func TestRejectAmbiguousKeysScalesLinearly(t *testing.T) { + var b strings.Builder + b.WriteString("{") + for i := 0; i < 90_000; i++ { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, `"k%d":1`, i) + } + b.WriteString("}") + var dst struct { + Owner string `json:"owner"` + } + start := time.Now() + if err := rejectAmbiguousKeys([]byte(b.String()), &dst); err != nil { + t.Fatal(err) + } + if d := time.Since(start); d > 2*time.Second { + t.Errorf("scan of 90k keys took %v", d) + } +} diff --git a/internal/tools/tool.go b/internal/tools/tool.go index 52eb640..ca79e9e 100644 --- a/internal/tools/tool.go +++ b/internal/tools/tool.go @@ -8,6 +8,7 @@ import ( "reflect" "regexp" "strings" + "unicode" "git.d-ma.be/mathias/gitea-mcp/internal/gitea" "git.d-ma.be/mathias/gitea-mcp/internal/registry" @@ -84,21 +85,18 @@ func scanJSONValue(dec *json.Decoder) error { } switch d { case '{': - var seen []string + seen := map[string]string{} for dec.More() { kt, err := dec.Token() if err != nil { return err } k, _ := kt.(string) - // EqualFold, not ToLower: encoding/json folds the same way, so - // long s (U+017F) matches s and the Kelvin sign matches k. - for _, prev := range seen { - if strings.EqualFold(prev, k) { - return fmt.Errorf("duplicate argument keys %q and %q (keys must be unique ignoring case): %w", prev, k, gitea.ErrValidation) - } + fk := foldKey(k) + if prev, dup := seen[fk]; dup { + return fmt.Errorf("duplicate argument keys %q and %q (keys must be unique ignoring case): %w", prev, k, gitea.ErrValidation) } - seen = append(seen, k) + seen[fk] = k if err := scanJSONValue(dec); err != nil { return err } @@ -114,6 +112,25 @@ func scanJSONValue(dec *json.Decoder) error { return err } +// foldKey maps every rune to the smallest member of its unicode.SimpleFold +// orbit, so foldKey(a) == foldKey(b) exactly when strings.EqualFold(a, b) — +// the folding encoding/json uses (long s == s, Kelvin sign == k). A map keyed +// on it keeps the duplicate scan linear (PR #66 review: a pairwise EqualFold +// loop cost 7s of CPU on ~90k keys). +func foldKey(s string) string { + var b strings.Builder + for _, r := range s { + min := r + for f := unicode.SimpleFold(r); f != r; f = unicode.SimpleFold(f) { + if f < min { + min = f + } + } + b.WriteRune(min) + } + return b.String() +} + // jsonFieldNames returns the json tag names of dst's top-level struct fields. func jsonFieldNames(dst any) []string { v := reflect.ValueOf(dst)