diff --git a/internal/engine/adapter_dispatch.go b/internal/engine/adapter_dispatch.go index 79391954..6e6b831e 100644 --- a/internal/engine/adapter_dispatch.go +++ b/internal/engine/adapter_dispatch.go @@ -7,6 +7,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "sync" @@ -379,12 +380,128 @@ func parseFormBody(raw string) map[string]any { } out := make(map[string]any, len(vals)) for k, vs := range vals { - if len(vs) > 0 { - out[k] = vs[0] + if len(vs) == 0 { + continue } + // Bracket-notation keys (a[b]=v, a[]=v, a[0][b]=v) expand into nested + // dicts/lists — provider SDKs (stripe-node, Octokit, …) POST + // urlencoded bodies in exactly this Rails/PHP shape, and handlers + // expect req["body"]["line_items"] to be a real list. Every value of + // a repeated bracket key appends (a[]=1&a[]=2). Malformed bracketing + // falls back to the flat first-value behavior. + if segs, ok := splitFormKey(k); ok && len(segs) > 1 { + for _, v := range vs { + assignFormValue(out, segs, v) + } + continue + } + out[k] = vs[0] } if len(out) == 0 { return nil } return out } + +// splitFormKey breaks "a[b][c]" into ["a","b","c"] and "a[]" into ["a",""]. +// ok is false when the brackets are malformed (unbalanced, trailing junk, +// missing bare key) — the caller then treats the whole key as flat. +func splitFormKey(k string) ([]string, bool) { + i := strings.IndexByte(k, '[') + if i < 0 { + return []string{k}, true + } + if i == 0 { + return nil, false // no bare key before the first bracket + } + segs := []string{k[:i]} + rest := k[i:] + for rest != "" { + if rest[0] != '[' { + return nil, false + } + j := strings.IndexByte(rest, ']') + if j < 1 { + return nil, false + } + segs = append(segs, rest[1:j]) + rest = rest[j+1:] + } + return segs, true +} + +// assignFormValue walks segs, materializing nested dicts and lists, and sets +// the final segment to val. "" means "append to a list"; a numeric segment +// indexes one (gaps become nil). Conflicting shapes at a path are skipped +// (first writer wins) rather than crashing the handler. +func assignFormValue(cur map[string]any, segs []string, val string) { + head := segs[0] + if len(segs) == 1 { + // A terminal scalar never clobbers an existing structure at the same + // path (ParseQuery's map iteration order makes "first writer" + // unenforceable; skip-instead-of-clobber is order-independent). + switch cur[head].(type) { + case map[string]any, []any: + return + } + cur[head] = val + return + } + tail := segs[1:] + switch { + case tail[0] == "": + l, _ := cur[head].([]any) + l = append(l, buildFormValue(tail[1:], val)) + cur[head] = l + case isNumericSegment(tail[0]): + idx, _ := strconv.Atoi(tail[0]) + l, _ := cur[head].([]any) + for len(l) <= idx { + l = append(l, nil) + } + if em, ok := l[idx].(map[string]any); ok { + assignFormValue(em, tail[1:], val) + } else if l[idx] == nil { + sub := map[string]any{} + assignFormValue(sub, tail[1:], val) + l[idx] = sub + } + cur[head] = l + default: + sub, ok := cur[head].(map[string]any) + if !ok { + if cur[head] != nil { + return // shape conflict; skip + } + sub = map[string]any{} + cur[head] = sub + } + assignFormValue(sub, tail, val) + } +} + +// buildFormValue materializes the value under a bare "a[]" append: the +// remaining segments nest inside the appended element. +func buildFormValue(segs []string, val string) any { + if len(segs) == 0 { + return val + } + if len(segs) == 1 { + return map[string]any{segs[0]: val} + } + sub := map[string]any{} + assignFormValue(sub, segs, val) + return sub +} + +func isNumericSegment(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} diff --git a/internal/engine/adapter_dispatch_parseform_test.go b/internal/engine/adapter_dispatch_parseform_test.go new file mode 100644 index 00000000..9fe80a4b --- /dev/null +++ b/internal/engine/adapter_dispatch_parseform_test.go @@ -0,0 +1,111 @@ +package engine + +import ( + "encoding/json" + "reflect" + "testing" +) + +// TestParseFormBodyBracketNotation pins the Rails/PHP bracket expansion used +// by provider SDKs (stripe-node POSTs line_items[0][price_data][currency]=… +// urlencoded). Handlers must see real nested dicts/lists in req["body"]. +func TestParseFormBodyBracketNotation(t *testing.T) { + cases := []struct { + name string + raw string + want string // JSON of the expected map + }{ + {"flat", "a=1&b=two", `{"a":"1","b":"two"}`}, + {"nested dict", "a[b]=1", `{"a":{"b":"1"}}`}, + {"deep dict", "a[b][c]=1", `{"a":{"b":{"c":"1"}}}`}, + {"bare append list", "a[]=1&a[]=2", `{"a":["1","2"]}`}, + // NOTE: repeated a[] values arrive under ONE ParseQuery key so their + // order is preserved; a[][k] pairs are separate keys whose relative + // map-iteration order is unspecified — asserted order-insensitively + // in TestParseFormBodyBareAppendDictsUnordered below. + {"numeric indexed merge", "a[0][b]=1&a[0][c]=2&a[1][b]=3", `{"a":[{"b":"1","c":"2"},{"b":"3"}]}`}, + {"flat + brackets coexist", "mode=payment&line_items[0][qty]=2", `{"mode":"payment","line_items":[{"qty":"2"}]}`}, + {"stripe-shaped", "line_items[0][price_data][currency]=usd&line_items[0][price_data][unit_amount]=1000&line_items[0][quantity]=1&success_url=https%3A%2F%2Fx.test%2Fs", + `{"line_items":[{"price_data":{"currency":"usd","unit_amount":"1000"},"quantity":"1"}],"success_url":"https://x.test/s"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseFormBody(tc.raw) + if got == nil { + t.Fatalf("parseFormBody(%q) = nil", tc.raw) + } + var want map[string]any + if err := json.Unmarshal([]byte(tc.want), &want); err != nil { + t.Fatalf("bad want JSON: %v", err) + } + if !reflect.DeepEqual(got, want) { + g, _ := json.Marshal(got) + w, _ := json.Marshal(want) + t.Fatalf("parseFormBody(%q)\n got %s\nwant %s", tc.raw, g, w) + } + }) + } +} + +// TestParseFormBodyBareAppendDictsUnordered proves a[][k] pairs append one +// dict each; their relative order follows ParseQuery's map iteration and is +// unspecified — only membership is asserted. +func TestParseFormBodyBareAppendDictsUnordered(t *testing.T) { + got := parseFormBody("a[][b]=1&a[][c]=2") + l, ok := got["a"].([]any) + if !ok || len(l) != 2 { + t.Fatalf("a = %v, want a 2-element list of dicts", got["a"]) + } + seen := map[string]string{} + for _, e := range l { + m, ok := e.(map[string]any) + if !ok || len(m) != 1 { + t.Fatalf("element %v, want single-key dict", e) + } + for k, v := range m { + seen[k] = v.(string) + } + } + if seen["b"] != "1" || seen["c"] != "2" { + t.Fatalf("appended dicts = %v, want {b:1, c:2}", seen) + } +} + +// TestParseFormBodyMalformedBracketsFallsBackFlat proves unbalanced bracket +// garbage degrades to the historical flat-key behavior instead of erroring. +func TestParseFormBodyMalformedBracketsFallsBackFlat(t *testing.T) { + got := parseFormBody("a[=1&b]=2") + if got == nil { + t.Fatal("parseFormBody returned nil for malformed brackets") + } + if got["a["] != "1" { + t.Errorf("a[ = %v, want 1 (flat fallback)", got["a["]) + } + if got["b]"] != "2" { + t.Errorf("b] = %v, want 2 (flat fallback)", got["b]"]) + } +} + +// TestParseFormBodyShapeConflictSkipped proves a scalar/structure collision +// at the same path never crashes the handler and never mixes shapes — exactly +// one of the two survives, regardless of ParseQuery's map iteration order. +func TestParseFormBodyShapeConflictSkipped(t *testing.T) { + for _, raw := range []string{"a=1&a[b]=2", "a[b]=1&a=2"} { + got := parseFormBody(raw) + if got == nil { + t.Fatalf("parseFormBody(%q) = nil", raw) + } + switch a := got["a"].(type) { + case string: + if a != "1" && a != "2" { + t.Errorf("parseFormBody(%q): a = %q, want the scalar", raw, a) + } + case map[string]any: + if a["b"] != "1" && a["b"] != "2" { + t.Errorf("parseFormBody(%q): a[b] = %v, want the dict value", raw, a["b"]) + } + default: + t.Errorf("parseFormBody(%q): a = %v (%T), want scalar or dict", raw, got["a"], got["a"]) + } + } +}