feat: add YAML support for persona files #58
@@ -34,21 +34,36 @@ func parsePersona(data []byte, source string) (*Persona, error) {
|
|||||||
### YAML Parsing with Depth Protection
|
### YAML Parsing with Depth Protection
|
||||||
|
|
||||||
|
|
|||||||
```go
|
```go
|
||||||
func parseYAML(data []byte, source string) (*Persona, error) {
|
func unmarshalYAMLWithDepthLimit(data []byte, out any, maxDepth int) error {
|
||||||
var p Persona
|
var node yaml.Node
|
||||||
// gopkg.in/yaml.v3 does NOT have built-in depth limiting.
|
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
// Use explicit depth check via yaml.Node API.
|
if err := dec.Decode(&node); err != nil {
|
||||||
if err := yaml.Unmarshal(data, &p); err != nil {
|
return err
|
||||||
return nil, fmt.Errorf("parse persona %s: %w", source, err)
|
|
||||||
}
|
}
|
||||||
if err := validatePersona(&p, source); err != nil {
|
if err := checkYAMLDepth(&node, 0, maxDepth); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
return &p, nil
|
return node.Decode(out)
|
||||||
|
}
|
||||||
|
sonnet-review-bot
commented
[NIT] The design doc's error cases table says 'Deeply nested YAML | Library rejects (v1.16.0+ fix)' but the actual implementation uses explicit depth checking via **[NIT]** The design doc's error cases table says 'Deeply nested YAML | Library rejects (v1.16.0+ fix)' but the actual implementation uses explicit depth checking via `checkYAMLDepth`, not a library-level fix. The implementation is correct (and better), but the design doc description is inaccurate.
|
|||||||
|
|
||||||
|
func checkYAMLDepth(node *yaml.Node, depth, maxDepth int) error {
|
||||||
|
if depth > maxDepth {
|
||||||
|
return fmt.Errorf("YAML nesting depth exceeds maximum (%d)", maxDepth)
|
||||||
|
}
|
||||||
|
// Handle alias nodes by following the Alias pointer
|
||||||
|
if node.Kind == yaml.AliasNode && node.Alias != nil {
|
||||||
|
return checkYAMLDepth(node.Alias, depth, maxDepth)
|
||||||
|
sonnet-review-bot
commented
[NIT] The design doc's Error Cases table says 'Deeply nested YAML | Library rejects (v1.16.0+ fix)' — but the actual implementation does not rely on library-level rejection; it implements its own depth check. This entry is misleading and should say 'Application-level depth check rejects' to accurately reflect the implementation. **[NIT]** The design doc's Error Cases table says 'Deeply nested YAML | Library rejects (v1.16.0+ fix)' — but the actual implementation does *not* rely on library-level rejection; it implements its own depth check. This entry is misleading and should say 'Application-level depth check rejects' to accurately reflect the implementation.
sonnet-review-bot
commented
[NIT] The design doc's Error Cases table says 'Deeply nested YAML | Library rejects (v1.16.0+ fix)' but the actual implementation handles this via the custom **[NIT]** The design doc's Error Cases table says 'Deeply nested YAML | Library rejects (v1.16.0+ fix)' but the actual implementation handles this via the custom `checkYAMLDepth` walker, not the library itself. The description is misleading and may cause confusion during future maintenance.
|
|||||||
|
}
|
||||||
|
for _, child := range node.Content {
|
||||||
|
if err := checkYAMLDepth(child, depth+1, maxDepth); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `gopkg.in/yaml.v3` library does not have built-in depth protection, so we implement explicit depth checking by first decoding into a `yaml.Node`, walking the tree to verify depth, then decoding into the target struct.
|
The `gopkg.in/yaml.v3` library does not have built-in depth protection, so we implement explicit depth checking by first decoding into a `yaml.Node`, walking the tree to verify depth (including alias resolution), then decoding into the target struct.
|
||||||
|
|
||||||
## State/Data Model
|
## State/Data Model
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ func LoadPersona(path string) (*Persona, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read persona file %s: %w", path, err)
|
return nil, fmt.Errorf("read persona file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return nil, fmt.Errorf("persona file %s is not a regular file", path)
|
||||||
|
}
|
||||||
if info.Size() > MaxPersonaFileSize {
|
if info.Size() > MaxPersonaFileSize {
|
||||||
return nil, fmt.Errorf("persona file %s exceeds maximum size (%d bytes)", path, MaxPersonaFileSize)
|
return nil, fmt.Errorf("persona file %s exceeds maximum size (%d bytes)", path, MaxPersonaFileSize)
|
||||||
}
|
}
|
||||||
@@ -59,6 +62,11 @@ func LoadPersona(path string) (*Persona, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read persona file %s: %w", path, err)
|
return nil, fmt.Errorf("read persona file %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
// Re-check size after read to defend against TOCTOU races where file
|
||||||
|
// grows between stat and read (e.g., appending process, replaced file).
|
||||||
|
if len(data) > MaxPersonaFileSize {
|
||||||
|
return nil, fmt.Errorf("persona file %s exceeds maximum size (%d bytes)", path, MaxPersonaFileSize)
|
||||||
|
}
|
||||||
return parsePersona(data, path)
|
return parsePersona(data, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +152,10 @@ func parsePersona(data []byte, source string) (*Persona, error) {
|
|||||||
|
|
||||||
// unmarshalYAMLWithDepthLimit unmarshals YAML data with explicit depth limiting.
|
// unmarshalYAMLWithDepthLimit unmarshals YAML data with explicit depth limiting.
|
||||||
// This protects against stack exhaustion from deeply nested structures.
|
// This protects against stack exhaustion from deeply nested structures.
|
||||||
func unmarshalYAMLWithDepthLimit(data []byte, out interface{}, maxDepth int) error {
|
// Note: Multi-document YAML files are accepted but only the first document is
|
||||||
|
// parsed; additional documents are silently ignored. This is acceptable for
|
||||||
|
// persona files where multi-document support is not a use case.
|
||||||
|
func unmarshalYAMLWithDepthLimit(data []byte, out any, maxDepth int) error {
|
||||||
var node yaml.Node
|
var node yaml.Node
|
||||||
dec := yaml.NewDecoder(bytes.NewReader(data))
|
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||||
if err := dec.Decode(&node); err != nil {
|
if err := dec.Decode(&node); err != nil {
|
||||||
@@ -159,10 +170,16 @@ func unmarshalYAMLWithDepthLimit(data []byte, out interface{}, maxDepth int) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkYAMLDepth recursively checks that YAML nodes don't exceed the depth limit.
|
// checkYAMLDepth recursively checks that YAML nodes don't exceed the depth limit.
|
||||||
|
// Handles alias nodes by following the Alias pointer to check the target's depth.
|
||||||
func checkYAMLDepth(node *yaml.Node, depth, maxDepth int) error {
|
func checkYAMLDepth(node *yaml.Node, depth, maxDepth int) error {
|
||||||
if depth > maxDepth {
|
if depth > maxDepth {
|
||||||
return fmt.Errorf("YAML nesting depth exceeds maximum (%d)", maxDepth)
|
return fmt.Errorf("YAML nesting depth exceeds maximum (%d)", maxDepth)
|
||||||
}
|
}
|
||||||
|
// Handle alias nodes: follow the alias to its anchor target.
|
||||||
|
// The alias itself doesn't add depth, but we must check the target.
|
||||||
|
if node.Kind == yaml.AliasNode && node.Alias != nil {
|
||||||
|
return checkYAMLDepth(node.Alias, depth, maxDepth)
|
||||||
|
}
|
||||||
for _, child := range node.Content {
|
for _, child := range node.Content {
|
||||||
if err := checkYAMLDepth(child, depth+1, maxDepth); err != nil {
|
if err := checkYAMLDepth(child, depth+1, maxDepth); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
[NIT] The design doc mentions updating the embed directive to include both formats, but the code embeds only *.yaml (and built-in JSON files were removed). Consider updating the doc to reflect the final decision to embed YAML only.