diff --git a/internal/tools/strict_args_test.go b/internal/tools/strict_args_test.go index f858134..0b8b906 100644 --- a/internal/tools/strict_args_test.go +++ b/internal/tools/strict_args_test.go @@ -39,6 +39,12 @@ func TestParseArgsRejectsCaseVariantKeys(t *testing.T) { "exact duplicate key": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","branch":"main"}`, "case-only schema mismatch": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","BRANCH":"main"}`, "nested object duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"a":1,"A":2}}`, + // PR #66 review: a number the token scanner cannot parse as float64 must + // not make the scan give up and let the duplicate through. + "1e999 decoy before duplicate": `{"owner":"probe","repo":"t","name":"t","path":"case8.txt","content":"x","message":"m","z":1e999,"branch":"feat/y8","Branch":"main"}`, + "-1e309 decoy before exact dup": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","z":-1e309,"branch":"feat/y8","branch":"main"}`, + // encoding/json folds like strings.EqualFold: long s (U+017F) == s. + "nested long-s duplicate": `{"owner":"probe","repo":"t","path":"p","content":"x","message":"m","branch":"feat/y8","extra":{"s":1,"\u017f":2}}`, } for name, raw := range cases { t.Run(name, func(t *testing.T) { diff --git a/internal/tools/tool.go b/internal/tools/tool.go index bf547ce..52eb640 100644 --- a/internal/tools/tool.go +++ b/internal/tools/tool.go @@ -45,11 +45,16 @@ func parseArgs(raw json.RawMessage, dst any) error { // no top-level key may differ from a field of dst only in case. func rejectAmbiguousKeys(raw json.RawMessage, dst any) error { dec := json.NewDecoder(bytes.NewReader(raw)) + // UseNumber: without it Token() fails on a number outside float64 range, + // which json.Unmarshal ignores in an unknown key — a decoy that would end + // the scan before the duplicate (PR #66 review). + dec.UseNumber() if err := scanJSONValue(dec); err != nil { if errors.Is(err, gitea.ErrValidation) { return err } - return nil // malformed JSON: let json.Unmarshal report it as before + // Fail closed: input the scanner cannot read is input it cannot vouch for. + return fmt.Errorf("arguments are not valid JSON: %v: %w", err, gitea.ErrValidation) } var top map[string]json.RawMessage if err := json.Unmarshal(raw, &top); err != nil { @@ -79,18 +84,21 @@ func scanJSONValue(dec *json.Decoder) error { } switch d { case '{': - seen := map[string]string{} + var seen []string for dec.More() { kt, err := dec.Token() if err != nil { return err } k, _ := kt.(string) - folded := strings.ToLower(k) - if prev, dup := seen[folded]; dup { - return fmt.Errorf("duplicate argument keys %q and %q (keys must be unique ignoring case): %w", prev, k, gitea.ErrValidation) + // 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) + } } - seen[folded] = k + seen = append(seen, k) if err := scanJSONValue(dec); err != nil { return err }