fix: address review #2888 findings (comment clarity, test cleanup)
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 18s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 37s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 1m0s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m11s

- Clarify depth-aware short-circuit comment to unambiguously describe
  the relationship between current depth and previous validation depth
- Add comment to MappingValueNode case explaining intentional depth+2
  behavior from parent MappingNode perspective
- Restructure unmarshalYAMLWithDepthLimit doc comment as bullet list
  covering all three safety checks (depth, multi-doc, strict fields)
- Replace t.Error with t.Fatal in TestYAMLEmptyFileRejection to remove
  redundant nil guard on subsequent err.Error() call
This commit is contained in:
claw
2026-05-12 19:06:52 -07:00
parent baa917f228
commit b9b7be3b4e
2 changed files with 18 additions and 11 deletions
+16 -9
View File
@@ -166,11 +166,10 @@ func parsePersona(data []byte, source string) (*Persona, error) {
return &p, nil return &p, nil
} }
// unmarshalYAMLWithDepthLimit unmarshals YAML data with explicit depth limiting // unmarshalYAMLWithDepthLimit unmarshals YAML data with three safety checks:
// and strict field checking. This protects against stack exhaustion from deeply // - Depth limiting: rejects AST trees exceeding maxDepth to prevent stack exhaustion.
// nested structures and catches typos in field names. // - Multi-document rejection: prevents silent data loss from ignored extra documents.
// Multi-document YAML files are rejected to prevent confusing behavior // - Strict field checking: rejects unknown YAML keys to catch typos early.
// where additional documents are silently ignored.
func unmarshalYAMLWithDepthLimit(data []byte, out any, maxDepth int) error { func unmarshalYAMLWithDepthLimit(data []byte, out any, maxDepth int) error {
// First pass: parse into AST to check depth limits, node counts, and // First pass: parse into AST to check depth limits, node counts, and
// multi-document rejection. This prevents stack exhaustion before we // multi-document rejection. This prevents stack exhaustion before we
@@ -247,10 +246,13 @@ func checkYAMLDepth(node ast.Node, depth, maxDepth, maxNodes int, validated map[
return fmt.Errorf("YAML node count exceeds maximum (%d)", maxNodes) return fmt.Errorf("YAML node count exceeds maximum (%d)", maxNodes)
} }
// Depth-aware short-circuit: only skip re-checking a node if we previously // Depth-aware short-circuit: skip re-validation only when the current visit
// validated it at the same or deeper effective depth. If this visit is at a // depth is the same or shallower than the depth at which this node was
// greater depth than before (e.g., alias referenced deeper in the tree), // previously validated. A shallower (or equal) current depth means the
// we must re-traverse to catch depth limit violations. // prior, deeper validation already covered any subtree depth violations.
// If the current depth exceeds the previous validation depth (e.g., an alias
// references this node deeper in the tree), we must re-traverse to ensure
// the combined effective depth doesn't exceed maxDepth.
// //
// Note: using ast.Node (interface) as map key relies on pointer identity, // Note: using ast.Node (interface) as map key relies on pointer identity,
// which is correct because all goccy/go-yaml AST node types are pointer // which is correct because all goccy/go-yaml AST node types are pointer
@@ -273,6 +275,11 @@ func checkYAMLDepth(node ast.Node, depth, maxDepth, maxNodes int, validated map[
} }
} }
case *ast.MappingValueNode: case *ast.MappingValueNode:
// Both Key and Value are visited at depth+1 relative to this
// MappingValueNode. Since MappingNode visits its MappingValueNode
// children at depth+1 as well, keys and values end up at depth+2
// from the parent MappingNode. This is intentional: it mirrors the
// actual nesting structure (mapping → key-value pair → key/value).
if err := checkYAMLDepth(n.Key, depth+1, maxDepth, maxNodes, validated, visiting, nodeCount); err != nil { if err := checkYAMLDepth(n.Key, depth+1, maxDepth, maxNodes, validated, visiting, nodeCount); err != nil {
return err return err
} }
+2 -2
View File
@@ -510,9 +510,9 @@ func TestYAMLEmptyFileRejection(t *testing.T) {
_, err := LoadPersona(path) _, err := LoadPersona(path)
if err == nil { if err == nil {
t.Error("expected error for empty YAML input, got nil") t.Fatal("expected error for empty YAML input, got nil")
} }
if err != nil && !strings.Contains(err.Error(), "empty YAML document") { if !strings.Contains(err.Error(), "empty YAML document") {
t.Errorf("expected error containing %q, got: %v", "empty YAML document", err) t.Errorf("expected error containing %q, got: %v", "empty YAML document", err)
} }
}) })