Skip to content

cxp-846 return XML as a generic map: map targets and non-map roots - #1061

Open
agustin-conductor wants to merge 3 commits into
mainfrom
bugfix/xml-parser
Open

cxp-846 return XML as a generic map: map targets and non-map roots#1061
agustin-conductor wants to merge 3 commits into
mainfrom
bugfix/xml-parser

Conversation

@agustin-conductor

@agustin-conductor agustin-conductor commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Two fixes that make uhttp able to hand an arbitrary XML document back as a generic map.

1. WithAlwaysXMLResponse rejected map targets. It hands its target straight to encoding/xml, which cannot unmarshal into a map, so a *map[string]any target failed for every response with a body:

failed to unmarshal xml response: unknown type map[string]interface {}. status code: 200

Route that one target type through the xmlMap decoder WithGenericResponse already uses, sharing the code as unmarshalXMLToMap.

2. A non-map XML root hard-failed. unmarshalXMLToMap required the root element's content to be a map and returned Internal: unsupported XML structure otherwise — which a root-level list hits, a common API shape. Key those documents by the root element name instead. Added in response to review feedback; unmarshalXMLToMap can no longer fail on structure at all.

3. The generic decoder had no recursion bound. unmarshalXMLElement recursed once per nesting level on a body read with an unbounded io.ReadAll, so deeply nested open tags exhausted the goroutine stack — and a Go stack overflow is fatal, not a panic, so recover() could not turn it back into a failed request. Measured: a 3.5 MB body of 500k nested tags decodes; a 7 MB body of 1M nested tags kills the process. Capped at 10000 levels. Also raised in review.

Scope note. An earlier revision reshaped the decoder so repeated siblings grouped under their shared key. That commit is dropped — see Deferred. No existing shape changes here: xml.go gains a root field and a depth bound, but the mapping from document to map is untouched.

Why

Callers wanting an arbitrary XML document as a map have no working option today. Concretely, baton-http maps parse_as: xml onto WithAlwaysXMLResponse(&map[string]any{}), so that config key has never functioned since it was added in 65f49692 — it hard-fails on every response with a body.

And fix 1 alone would not have been enough for the shape that matters most. <Users><User/><User/></Users> decodes to a []map[string]any, so it still died in unmarshalXMLToMap — arguably the main case parse_as: xml is wanted for.

Compatibility

Scoped by target type. The new branch in WithAlwaysXMLResponse fires only for *map[string]any; every other target falls through to the unchanged xml.Unmarshal call. All existing call sites in the connector fleet pass typed structs or nil. WithXMLResponse — which panorama, litmos, and sage-intacct use — is not modified, and a test pins that it still rejects map targets.

With one exception noted below, every divergence is error → something else, never success → something else:

input, map target before after
XML body error unknown type map[string]interface {} decoded map
204 / empty 2xx body error nil, map left empty
typed-nil (*map[string]any)(nil) error nil pointer passed to Unmarshal InvalidArgument: response is nil
root's children repeat (<Users><User/><User/></Users>) error unsupported XML structure: []map[string]interface {} {"Users": [{"User":…},{"User":…}]}
root holds only text (<Code>OK</Code>) error unsupported XML structure: string {"Code": "OK"}

The one exception is the depth cap. A body nested past 10000 levels previously did not error — it took the process down with a fatal stack overflow. It now returns an error. That is the improvement, but unlike everything else here it is a behavior change on input that did not previously fail, and it applies to WithGenericResponse as well, which already reached the decoder for any XML content-type response. Real documents nest tens of levels at most, so the limit is ~300x clear of anything legitimate, and encoding/json caps nesting at 10000 for the same reason.

The last two rows also apply to WithGenericResponse, since both paths share unmarshalXMLToMap. Both were hard errors there too, so the same argument covers it — but note that this PR does change generic-path behavior for those two document shapes, not only the map target.

WithAlwaysXMLResponse and WithGenericResponse now produce byte-identical output for the same document, asserted by test. That matters downstream: a baton-http config gets the same tree whether or not it sets parse_as: xml.

The WithGenericResponse refactor is a pure extraction: its XML branch previously routed through WithXMLResponse(&xm), whose content-type and nil checks are both dead inside that branch (already guarded by IsXMLContentType, and &xm is never nil). Same decoder, same error wrapping, one code path.

The typed-nil guard addresses the earlier review finding: the map branch would have turned encoding/xml's clean "nil pointer passed to Unmarshal" into a nil-pointer panic. It lives inside unmarshalXMLToMap rather than at each call site, so assigning through the pointer cannot panic. A typed nil survives an any == nil check because the interface still carries a type.

Known limitation: the arity seam

Keying by the root name is reactive, so it inherits the decoder's arity asymmetry:

document decoded path
<users><user/><user/></users> {"users": [{"user":…},{"user":…}]} users
<users><user/></users> {"user": {…}} user

One child means nothing repeats, so the content is a map, so the root name is discarded as always. One config cannot serve both arities for a root-level list. This is pinned by a test rather than left to be rediscovered.

It is still a clear improvement: previously the ≥2 case — the one essentially every real tenant hits — failed outright, and the error it produced was an opaque Internal from inside the SDK rather than something a config author could act on.

Grouping repeated children under their shared name is what closes the seam, and it would make the slice case here unreachable. Nested lists already have no seam, thanks to ConductorOne/baton-http#144.

Testing

go build ./..., go test ./pkg/uhttp/..., and golangci-lint run ./pkg/uhttp/... (0 issues) all pass.

Depth cap tested in both directions — a body at 2× the limit errors, and a body at the limit still decodes, so the cap cannot later be tightened into one that rejects real responses.

Cases on WithAlwaysXMLResponse: map target decoding despite a non-XML content type, the typed-struct path unchanged, a root-level list keyed by the root name, a single-child root still stripping it (the seam), a text-only root keyed by the root name, 204 and empty-200 leaving the map untouched, a typed-nil target erroring rather than panicking, and WithXMLResponse still rejecting map targets.

Verified end-to-end against baton-http#144 through a Go workspace — raw XML → WithGenericResponseExtractItems:

root-level list, 2 users   items_path=users   2 items    (was: SDK hard error)
root-level list, 1 user    items_path=user    1 item
nested list, 2 users       items_path=users   2 items
nested list, 1 user        items_path=users   1 item     <- same path both arities
text-only root             items_path=code    correctly "not an array, got string"

Deferred: the decoder shape change

The dropped commit made a container with 2+ same-named children decode to {"USER_LIST": {"USER": [...]}} instead of {"USER_LIST": [{"USER":…},{"USER":…}]}, so that jsonpath could walk it.

It is not needed for the consumer problem it targeted — baton-http's items_path failing on XML list responses — which is fixed entirely by ConductorOne/baton-http#144, at the extraction sites, with no SDK release.

And it carries a risk this PR does not. []map[string]any is only untraversable for jsonpath; CEL and Go templates walk it fine. In baton-http, responses on the provisioning, action, and pre-request paths never reach items extraction and are read solely by CEL — so cel:size(response.body.USER_LIST) returns N today and would return 1 after the reshape: a silent wrong answer in a config that works.

Worth noting it has gained a second motivation, though — it is also what would close the arity seam above and retire the slice branch entirely. So it is deferred for its own audit, not dismissed.

Part of CXP-846

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Aug 5, 2026

Copy link
Copy Markdown

CXP-846

Comment thread pkg/uhttp/wrapper.go
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(resp.Body) == 0 {
return nil
}
return unmarshalXMLToMap(genericResponse, resp)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: A typed-nil (*map[string]any)(nil) passes the response == nil check above (interface holds a type), then this assertion succeeds with a nil genericResponse, and unmarshalXMLToMap does *response = vMap → nil-pointer panic on a non-empty body. WithGenericResponse guards this with an explicit nil check; consider mirroring it here. Low confidence — an unusual call pattern, but the map branch is new. (confidence: low)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — confirmed, and it's a regression rather than a latent edge case, so fixed in 0cf06e7.

Verified the premise and the prior behavior:

iface == nil?            false                            // typed nil carries a type, so it passes the guard
xml.Unmarshal(typedNil): nil pointer passed to Unmarshal  // old behavior: clean error

So routing map targets through xmlMap turned that clean error into a panic. Reverting just the guard and running the new test reproduces it:

panic: runtime error: invalid memory address or nil pointer dereference

Guarded inside unmarshalXMLToMap rather than in WithAlwaysXMLResponse, so WithGenericResponse and any future caller are covered by the same check and it can't be reintroduced at a new call site. Returns InvalidArgument to match WithGenericResponse's existing nil handling. Test added: should error rather than panic on a typed-nil map target.

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

General PR Review: cxp-846 fix XML list decoding in the generic XML decoder

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 5a7eaa0cca95.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness. This change reshapes xmlMap so repeated XML siblings group into a []any under their shared element name (replacing the old slice-of-single-key-maps shape that jsonpath could not walk), and lets WithAlwaysXMLResponse decode a map-pointer target through that decoder. The decode logic is correct — document order is preserved, unique siblings stay reachable alongside repeated ones, and the single-child asymmetry is documented — and the permutation-style test table (3+ duplicates, mixed siblings, nested depths, single-child, 204/empty-body, struct-path-unchanged) exercises the shape thoroughly. Triage: the failure mode is silent-empty (high silence) but the output is decode-time in-memory only, not durable serialized state, has no version-pair or scale dependence, and remediation is a connector redeploy — not a HIGH-risk contract change. This is a deliberate, well-documented default-shape change to a shared decoder; downstream consumers of the XML output should be aware, though the old shape was demonstrably unusable by config-driven callers. No blocking issues found.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/uhttp/wrapper.go:239 — a typed-nil map-pointer target bypasses the response == nil guard and would nil-panic at the map assignment in unmarshalXMLToMap; consider mirroring the explicit nil check WithGenericResponse uses. (low confidence)
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In pkg/uhttp/wrapper.go:
- Around line 230-239: The new map-pointer branch in WithAlwaysXMLResponse
  type-asserts response to a map pointer and passes it to unmarshalXMLToMap,
  which ends by assigning through the pointer. A caller passing a typed-nil map
  pointer is not caught by the earlier response == nil check (an interface holding
  a nil typed pointer is not equal to nil), so with a non-empty body the code
  dereferences a nil pointer and panics. Add an explicit guard for a nil map
  pointer (return nil, or return an InvalidArgument status), mirroring the
  response == nil guard already present in WithGenericResponse. Low-likelihood
  call pattern but the branch is new, so guarding it costs nothing.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

agustin-conductor added a commit that referenced this pull request Aug 5, 2026
A typed-nil target such as (*map[string]any)(nil) gets past the
`response == nil` check in WithAlwaysXMLResponse, because the interface
still carries a type. The map branch was then reached with a nil pointer
and assigning through it panicked with a nil-pointer dereference.

encoding/xml rejected that input with "nil pointer passed to Unmarshal",
so routing map targets through xmlMap had turned a clean error into a
panic. Guard inside unmarshalXMLToMap rather than at each call site, so
neither this option nor WithGenericResponse nor any future caller can
assign through a nil pointer.

Reported by the PR review bot on #1061.

CXP-846

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

General PR Review: cxp-846 return XML as a generic map: map targets and non-map roots

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base abc69f3badc5.
Review mode: incremental since 681b0672
View review run

Review Summary

The new commit (cf7410f) adds a maxXMLDepth = 10_000 cap to unmarshalXMLElement plus two-sided boundary tests, converting a fatal stack overflow on deeply nested bodies into a returned error — a direct response to the resource-exhaustion exposure this branch's new WithAlwaysXMLResponse map path widens. The full PR diff was scanned for security and correctness; the depth guard, its off-by-one (depth > max from a root at depth 0), and the recursion threading are correct, and the non-2xx path in BaseHttpClient.Do still errors regardless of DoOption success, so the widened map target cannot turn an error response into apparent data. Prior findings on the typed-nil map target now appear addressed by the explicit response == nil guard and its test in unmarshalXMLToMap; the earlier WithGenericResponse doc-comment/arity-seam findings are unchanged in this commit and are not re-raised here.

Risk triage (per docs/BUG_CATCHING.md §2) — Silence: no, the failure mode is an explicit error and the prior behavior was a loud crash. Durability: no, in-process decode only, nothing serialized. Uncontrolled dimensions: yes, correctness of the constant depends on body volume/nesting, which fixtures undersample. Consumer distance: yes, downstream connectors reach this through WithGenericResponse/WithAlwaysXMLResponse. Consequence: rung 1 (redeploy) — no artifacts to migrate, no external side effects. Two escape yeses point at HIGH, but rung-1 consequence plus the permutation instrument already present in the diff (reject past limit and accept at limit, so the constant cannot later be tightened into rejecting real responses) puts the actionable risk at MEDIUM. The one instrument the diff does not carry is a cost-curve benchmark on the breadth dimension, which is the suggestion below.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/uhttp/xml.go:52 — the depth cap bounds recursion but not breadth: a wide document (<r><a/><a/>…</r>) still amplifies body size ~100x into single-key maps, so the same ~7 MB body cited as fatal-by-depth becomes an OOM by breadth. Pre-existing and reachable before this PR, but unbounded and unbenchmarked in the commit that hardens this decoder. (medium confidence)
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/uhttp/xml.go`:
- Around line 52 (the new maxXMLDepth guard in unmarshalXMLElement): the new cap
  bounds recursion depth but leaves breadth unbounded, so the resource-exhaustion
  path this commit hardens is only half closed. For a wide body such as
  `<r><a/><a/>...</r>`, every 4-byte sibling allocates an `entry` (~32B) plus an
  empty `map[string]any`, and because repeated child names set `hasDuplicates`,
  the returned `[]map[string]any` holds one single-key map per sibling (hmap plus
  one bucket, ~300B). That is roughly 100x heap amplification on a body already
  read with an unbounded `io.ReadAll`, so the same ~7 MB response the commit
  message measures as fatal-by-depth instead dies by OOM.
  Fix options, in order of preference: (1) thread a shared budget alongside
  `depth` — e.g. a pointer to a total-element or total-entry counter incremented
  per StartElement and checked against a constant such as maxXMLElements, so a
  wide document fails as a request error the same way a deep one now does; or
  (2) if breadth is knowingly deferred to a later change, say so explicitly in
  the `maxXMLDepth` doc comment so the depth cap is not read as full coverage of
  the unbounded-body exposure. Either way, add a benchmark or a table-driven
  size/cost case that pins the heap cost curve in elements, matching the
  two-sided boundary tests that already pin the depth limit.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Comment thread pkg/uhttp/xml.go Outdated
// zero value of the assertion is a nil []any, which append handles,
// so the first occurrence creates the slice.
list, _ := result[e.key].([]any)
result[e.key] = append(list, e.value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this changes the structure of decoded XML, any connector that uses WithXMLResponse/WithGenericResponse will need to be updated, right? It looks like only a few connectors call WithXMLResponse directly: https://github.com/search?q=org%3AConductorOne+WithXMLResponse&type=code and only baton-http calls WithGenericResponse(), so that's acceptable.

Will an existing baton-http config break because of this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on what I've research with claude the baton connectors would not be affected "No updates needed for panorama, litmos, sage-intacct, or sap-grc. xmlMap is
unreachable from WithXMLResponse, all their targets are typed structs, and
they build and test identically against patched vs unpatched v0.22.0."

But baton-http is trickier and I'm not sure how to evaluate the impact, which would depend on how the config.yaml is set.
2 paths

mechanism: jsonpath
used by: items_path, item_path, entitlements_path, resources_path,
details/secondary EvaluateJSONPath
today: broken — error, or silently 0 items
after my change: fixed

mechanism: CEL / templates
used by: cel: and tmpl: expressions
today: works correctly
after my change: breaks — loud on indexing, silent N → 1 on size/len

the second one is a problem, silently losing pages.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we're safe to make this change. There are no active http connectors in prod that use this part of the config.

@agustin-conductor
agustin-conductor marked this pull request as draft August 6, 2026 18:14
@agustin-conductor agustin-conductor changed the title cxp-846 fix XML list decoding in the generic XML decoder cxp-846 decode XML into a map target in WithAlwaysXMLResponse Aug 6, 2026
@agustin-conductor
agustin-conductor marked this pull request as ready for review August 6, 2026 20:38
encoding/xml cannot unmarshal into a map, so WithAlwaysXMLResponse failed
for every response with a body when handed a *map[string]any, returning
"unknown type map[string]interface {}". Callers wanting an arbitrary XML
document as a map had no working option, which is why baton-http's
`parse_as: xml` has never functioned.

Route that one target type through the xmlMap decoder the generic path
already uses, and share the code as unmarshalXMLToMap. Any other target
still goes straight to xml.Unmarshal, so callers passing a typed struct
are untouched, and WithXMLResponse is not modified at all.

This changes no shapes: the map target now produces exactly what
WithGenericResponse already produces for the same document.

The behavior change is confined to a branch that previously always
failed:

  XML body, map target       error "unknown type map…"      -> decoded map
  204 / empty body           error                          -> nil, map empty
  typed-nil map target       error "nil pointer passed…"    -> InvalidArgument
  root holds only text       error                          -> Internal

Nothing that returns successfully today returns anything different. The
typed-nil guard lives inside unmarshalXMLToMap so assigning through the
pointer cannot panic; a typed nil survives an `any == nil` check because
the interface still carries a type.

Part of CXP-846.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread pkg/uhttp/wrapper.go
Comment on lines +265 to +270
vMap, ok := xm.data.(map[string]any)
if !ok {
// A document whose root holds only text decodes to a string, which has no
// sensible map representation.
return status.Errorf(codes.Internal, "unsupported XML structure: %T", xm.data)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the comment says the non-map case is "a document whose root holds only text", but unmarshalXMLElement also returns []map[string]any whenever the root's direct children repeat (see xml_test.go:24). So a very common list shape — <Users><User>…</User><User>…</User></Users> — still hard-fails here with Internal: unsupported XML structure: []map[string]interface {}, which is arguably the main case parse_as: xml needs. Pre-existing in WithGenericResponse and not a regression, but worth either handling the slice case (e.g. wrap it under the root element name) or at least correcting the comment and adding a test so the limitation is explicit. (medium confidence)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on both halves — the comment was wrong, and the slice case is the more important one. Fixed in 681b067.

The comment. Corrected to name both ways the root's content can be a non-map, since I'd only documented the string case.

The slice case. Handled as you suggested, keyed by the root element name — xmlMap now records start.Name.Local, which it was discarding:

<users><user><login>a</login></user><user><login>b</login></user></users>
  before: Internal: unsupported XML structure: []map[string]interface {}
  after:  {"users": [{"user":{"login":"a"}}, {"user":{"login":"b"}}]}

I did the text-only root the same way (<Code>OK</Code>{"Code": "OK"}), so unmarshalXMLToMap can no longer fail on structure at all. Both were errors before, here and on WithGenericResponse, so it stays error → success.

One caveat worth recording, since wrapping is reactive rather than a real fix. It inherits the decoder's arity asymmetry:

document decoded path
<users><user/><user/></users> {"users": [{"user":…},{"user":…}]} users
<users><user/></users> {"user": {…}} user

A single child means nothing repeats, so the content is a map, so the root name is discarded as usual — which means one config can't serve both arities for a root-level list. Measured, not assumed, and pinned by should keep stripping the root when its content is a map so it can't drift silently.

Still a clear win: the ≥2 case is the one every real tenant hits, and it went from an opaque SDK-internal error to something reachable by a path.

What actually closes the seam is grouping repeated children under their shared name — which would also make this slice branch unreachable. That was in an earlier revision of this PR and is deferred (see the PR description) because it changes what existing CEL and template expressions read on paths that never touch items extraction. Your finding is a second argument for it, so it's parked for its own audit rather than dropped.

Verified end-to-end against ConductorOne/baton-http#144 through a Go workspace: root-level lists now sync at both arities (with the path difference above), nested lists sync at both arities from a single path.

Comment thread pkg/uhttp/wrapper.go
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(resp.Body) == 0 {
return nil
}
return unmarshalXMLToMap(genericResponse, resp)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this newly routes bodies that previously always errored into unmarshalXMLElement, which recurses once per nesting level with no depth cap, on a body read with an unbounded io.ReadAll (wrapper.go:503) and with the content-type check bypassed by design. A few MB of nested open tags from a hostile or broken endpoint is a fatal (unrecoverable) stack overflow rather than a returned error. Pre-existing in the WithGenericResponse path, so not introduced here, but a depth limit in unmarshalXMLElement would cheaply close it. (low confidence on real-world reachability)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ is there any easy way to address this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cf7410f8: added a depth cap of 10000 to unmarshalXMLElement.

Real XML nests tens of levels, so 10000 covers production cases with plenty of room while staying well under the stack limit — a pathological body now returns an error instead of a fatal stack overflow that recover() can't catch. (Measured: ~1M levels / 7 MB body is fatal, ~500k isn't.) encoding/json uses the same 10000 for the same reason.

Applies to WithGenericResponse too, which already reached this decoder.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

unmarshalXMLToMap required the root element's content to be a map and
returned Internal "unsupported XML structure" otherwise. That is
reachable two ways, and the review flagged that the comment named only
the second:

  - The root's own children repeat: <Users><User/><User/></Users> decodes
    to a []map[string]any. A root-level list is a common API shape, so
    this hard-failed for a whole class of responses -- arguably the main
    case parse_as: xml is wanted for.
  - The root holds only text: <Code>OK</Code> decodes to a string.

Key both by the root element name, which the decoder recorded nowhere and
otherwise discards, so the document is reachable by a path instead of
being an error. unmarshalXMLToMap can no longer fail on structure at all.

Both cases were errors before, on this path and on WithGenericResponse,
so this stays error -> success with no working caller affected. xml.go
gains a root field but no decoding change, so shapes are unchanged.

Leaves an arity seam, now pinned by a test: a root holding a single
<User> decodes to a map and keeps the root stripped, so its path is
"User" while the repeated case is "Users", and one config cannot serve
both. Grouping repeated children under their shared name is what closes
that, and would make the slice case here unreachable.

Part of CXP-846.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread pkg/uhttp/wrapper.go
return status.Errorf(codes.Internal, "unsupported XML structure: %T", xm.data)
}
return nil
return unmarshalXMLToMap(response, resp)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: This is no longer a pure extraction — the shared helper's new root-keying changes WithGenericResponse's observable behavior. <users><user/><user/></users> used to return Internal: unsupported XML structure: []map[string]interface {} and now succeeds as {"users": [...]}; <Code>OK</Code> used to error and now returns {"Code": "OK"}. That direction is error→success so it can't break a working caller, but it means the arity seam documented at lines 278-282 now also applies to this already-shipping API: the same endpoint keys on the root name at 2+ items and on the child name at 1 item, and the 2-item case used to be a loud error rather than a silently different key. The new tests all go through WithAlwaysXMLResponse; TestWrapper_WithGenericResponse has no case pinning either new shape. Worth adding the 1-item/N-item pair there directly, and correcting the PR description, which still says this branch is "same decoder, same error wrapping" and still lists root-text as producing Internal: unsupported XML structure: string. (medium confidence)

Comment thread pkg/uhttp/wrapper.go
// repeated case is "Users". One config cannot serve both. Closing that
// needs the decoder to group repeated children under their shared name,
// which would also make the slice case here unreachable.
*response = map[string]any{xm.root: xm.data}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Because WithGenericResponse now shares this helper, this line also changes that function's documented contract. Its doc comment (line 389) says "if the response is a list, its values will be put into the items field" — the JSON branch still honors that, but an XML root-level list now lands under the root element's own name instead. Worth updating that comment so the public contract matches both branches. (medium confidence)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@agustin-conductor agustin-conductor changed the title cxp-846 decode XML into a map target in WithAlwaysXMLResponse cxp-846 return XML as a generic map: map targets and non-map roots Aug 11, 2026
@btipling btipling removed their assignment Aug 12, 2026
unmarshalXMLElement recurses once per nesting level with no bound, on a
body read with an unbounded io.ReadAll. Deeply nested open tags therefore
exhaust the goroutine stack, and a Go stack overflow is a fatal runtime
error rather than a panic: recover() cannot turn it back into a failed
request, so the process dies mid-sync.

Measured against the 1 GB default stack limit: a 3.5 MB body of 500k
nested tags decodes, and a 7 MB body of 1M nested tags is fatal. That is
a small payload for an endpoint to return.

Cap the recursion at 10000 levels and return an error instead. Real
documents nest tens of levels at most, so the limit is ~300x clear of
anything legitimate, and encoding/json caps nesting at 10000 for the same
reason. Tested in both directions so the limit cannot later be tightened
into one that rejects real responses.

This closes the recursion path for every caller of the generic decoder,
so it also applies to WithGenericResponse, which reached it already for
any XML content-type response. Raised in review of the map-target change,
which adds a second way in: WithAlwaysXMLResponse skips the content-type
check by design, so a parse_as: xml config parses whatever a server
returns.

Note this is not the error -> success shape of the rest of this branch: a
body nested past the limit now returns an error where it previously took
the process down. That is the improvement, but it is a behavior change on
input that did not previously error.

Part of CXP-846.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread pkg/uhttp/xml.go
// pathologically nested body fails as a request error instead of taking the
// process down with a stack overflow.
func unmarshalXMLElement(d *xml.Decoder, depth int) (any, error) {
if depth > maxXMLDepth {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The depth cap closes the fatal case (stack overflow), but the other half of the same unbounded-io.ReadAll exposure is breadth, not depth, and it stays open. For <r><a/><a/>…</r>, each 4-byte sibling costs an entry (32B) plus an empty map[string]any, and because hasDuplicates trips, the result is one single-key map per sibling (hmap + a bucket, ~300B). That's roughly a 100x heap amplification, so the same ~7 MB body the commit message cites as fatal-by-depth becomes several hundred MB by breadth — OOM rather than stack overflow, but still process death mid-sync from one response.

This is pre-existing and reachable before this PR via WithGenericResponse, so not a blocker on this diff. But the PR is explicitly the resource-exhaustion hardening for this decoder, and the depth constant was chosen from a measured ceiling with a two-sided boundary test — the breadth dimension has neither a bound nor a benchmark pinning the cost curve. Worth either capping total element/entry count in the same change, or noting explicitly that breadth is knowingly deferred so the next reader doesn't take the depth cap as full coverage. (medium confidence)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@agustin-conductor
agustin-conductor requested a review from kans August 14, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants