Skip to content

Bound merge-key expansion depth on borrowed and value deserializers#60

Merged
jskoiz merged 2 commits into
mainfrom
fix/bound-borrowed-merge-recursion
Jun 5, 2026
Merged

Bound merge-key expansion depth on borrowed and value deserializers#60
jskoiz merged 2 commits into
mainfrom
fix/bound-borrowed-merge-recursion

Conversation

@jskoiz

@jskoiz jskoiz commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Summary

The borrowed &Node/&Value Serde deserializers were taught to expand << merge keys but were given a merge engine with no depth limit. A deeply nested caller-built merge chain could:

  1. Exhaust the stack and abort the process with fatal runtime error: stack overflow (via &Node/from_node and &Value).
  2. Diverge from the owned Node path, which already caps merge expansion at the default nesting depth (MAX_MERGE_DEPTH = 128) in merge_node_mapping. On the same hand-built structure, the owned Node deserializer returned a depth-exceeded Err while the borrowed paths returned Ok (or crashed).

The owned Value::apply_merge (used by from_value and the owned Value deserializer) was iterative — so it never overflowed — but it also had no merge-depth limit, so it disagreed with the capped paths on deep chains.

Fix

  • Thread a merge-depth budget through the borrowed ref-merge functions (merged_node_ref_entries/node_ref_merge_entries and merged_value_ref_entries/value_ref_merge_entries), enforcing the same MAX_MERGE_DEPTH (128) as the owned Node path and returning the same depth-exceeded error.
  • Carry a per-link merge-depth budget through the iterative Value::apply_merge, so from_value and the owned Value deserializer cap at the same boundary while preserving the existing serde_yaml-compatible (partial) resolution semantics for shallow merges.

After the fix, at the boundary the owned Node, &Node, from_node, from_value, and &Value paths all agree: a chain at the cap resolves, and a chain past it fails with the same ErrorCategory::Limit "maximum YAML nesting depth exceeded" error. No path overflows the stack.

Normal-depth merges (untagged <<, explicit merge tags, merge lists, nested merges, override precedence, and parser-produced configs) are unchanged.

Tests

  • Adds serde_api_caller_built_merge_depth_limit_is_consistent_across_paths in tests/serde_value_api.rs, asserting that a merge chain at depth 128 resolves on all five paths and a chain at depth 160 fails with the same depth-limit error on all five — exercising the cap well before recursion could approach a real stack overflow.
  • Full suite green, including the serde_yaml-parity corpus (parser_properties), merge_defaults, serde_yaml_swap_harness, schema_modes, yaml11_conformance, and dos_hardening.

No public API change (docs/PUBLIC_API.txt unaffected).

The borrowed &Node/&Value Serde deserializers expanded << merge keys
without any recursion limit, so a deeply nested caller-built merge chain
could exhaust the stack and abort the process. They also disagreed with
the owned Node path, which caps merge expansion at the default nesting
depth. Value::apply_merge (used by from_value and the owned Value
deserializer) likewise had no merge-depth limit.

Thread a merge-depth budget through the borrowed ref-merge functions and
through Value::apply_merge so every deserialization path enforces the
same cap and reports the same depth-exceeded error. Merge chains at the
limit still expand normally; chains beyond it now fail consistently
instead of overflowing the stack.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 439ecdeafd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/ast.rs
// accounting in step with the per-`<<` recursion the borrowed
// ref-merge paths perform; every other entry restarts its own
// chain at depth zero.
push_mapping_values(&mut values, mapping, next_depth + 1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Don't double-charge residual merge depth

For caller-built Value merge chains, queuing the residual << value at next_depth + 1 makes the next loop iteration charge that same merge edge again before expanding it. This causes Value::apply_merge/from_value to reject valid chains well below the documented 128-link cap (a 65-link chain reaches depth = 128 and then fails on the next charge), while the borrowed &Value/&Node paths continue to count one level per <<. Queue the residual merge at next_depth so the following iteration accounts for only the next merge key.

Useful? React with 👍 / 👎.

@jskoiz jskoiz merged commit b53a094 into main Jun 5, 2026
7 checks passed
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.

1 participant