fix(tools): fail closed when the key scan cannot read the input

PR #66 review: json.Decoder.Token() fails on a number outside float64
range (1e999), which json.Unmarshal ignores in an unknown key, so a
decoy key ended the scan and the #65 repro wrote to main. The decoder
now uses UseNumber, and any scan error is a validation error. The
duplicate check folds with strings.EqualFold (as encoding/json does)
instead of ToLower, so long s and the Kelvin sign are caught in nested
objects too.

Refs #65

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 07:02:27 +02:00
co-authored by Claude Opus 5.5
parent ae50501ba3
commit 31a3dd3e0e
2 changed files with 20 additions and 6 deletions
+6
View File
@@ -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"}`, "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"}`, "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}}`, "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 { for name, raw := range cases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
+14 -6
View File
@@ -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. // no top-level key may differ from a field of dst only in case.
func rejectAmbiguousKeys(raw json.RawMessage, dst any) error { func rejectAmbiguousKeys(raw json.RawMessage, dst any) error {
dec := json.NewDecoder(bytes.NewReader(raw)) 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 err := scanJSONValue(dec); err != nil {
if errors.Is(err, gitea.ErrValidation) { if errors.Is(err, gitea.ErrValidation) {
return err 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 var top map[string]json.RawMessage
if err := json.Unmarshal(raw, &top); err != nil { if err := json.Unmarshal(raw, &top); err != nil {
@@ -79,18 +84,21 @@ func scanJSONValue(dec *json.Decoder) error {
} }
switch d { switch d {
case '{': case '{':
seen := map[string]string{} var seen []string
for dec.More() { for dec.More() {
kt, err := dec.Token() kt, err := dec.Token()
if err != nil { if err != nil {
return err return err
} }
k, _ := kt.(string) k, _ := kt.(string)
folded := strings.ToLower(k) // EqualFold, not ToLower: encoding/json folds the same way, so
if prev, dup := seen[folded]; dup { // long s (U+017F) matches s and the Kelvin sign matches k.
return fmt.Errorf("duplicate argument keys %q and %q (keys must be unique ignoring case): %w", prev, k, gitea.ErrValidation) 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 { if err := scanJSONValue(dec); err != nil {
return err return err
} }