Compare commits
5 Commits
7898dd939f
...
issue-57
| Author | SHA1 | Date | |
|---|---|---|---|
| 10cd6203d4 | |||
| 26f326cf51 | |||
| 4fed59ac85 | |||
| 6035afeea7 | |||
| c3e8f0f231 |
@@ -9,7 +9,7 @@ AI-powered code review bot for Gitea pull requests. Fetches diff + context, send
|
||||
- **Smart budget**: Automatically trims context to fit model token limits
|
||||
- **Idempotent reviews**: Posts new review, then cleans up stale ones (one review per bot)
|
||||
- **Custom prompts**: Load additional instructions from a file (e.g. security-focused review)
|
||||
- **Zero dependencies**: Go stdlib only
|
||||
- **Minimal dependencies**: Go stdlib + `gopkg.in/yaml.v3` only
|
||||
|
||||
## Quick Start: Composite Action
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ JSON is awkward for persona files that contain multi-line text (identity, severi
|
||||
- Backwards compatibility: existing JSON personas must continue to work
|
||||
- Security: protect against DoS via deeply nested YAML (AIKIDO-2024-10486)
|
||||
- Consistency: use `.yaml` extension (not `.yml`)
|
||||
- Library: use `github.com/goccy/go-yaml` v1.16.0+ (actively maintained, security fix applied)
|
||||
- Library: use `gopkg.in/yaml.v3` (approved in CONVENTIONS.md) with explicit depth limiting
|
||||
|
||||
## Proposed Approach
|
||||
|
||||
@@ -34,21 +34,36 @@ func parsePersona(data []byte, source string) (*Persona, error) {
|
||||
### YAML Parsing with Depth Protection
|
||||
|
||||
```go
|
||||
func parseYAML(data []byte, source string) (*Persona, error) {
|
||||
var p Persona
|
||||
// go-yaml has built-in protection against deeply nested structures
|
||||
// but we add explicit decoder options for defense in depth
|
||||
if err := yaml.Unmarshal(data, &p); err != nil {
|
||||
return nil, fmt.Errorf("parse persona %s: %w", source, err)
|
||||
func unmarshalYAMLWithDepthLimit(data []byte, out any, maxDepth int) error {
|
||||
var node yaml.Node
|
||||
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||
if err := dec.Decode(&node); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePersona(&p, source); err != nil {
|
||||
return nil, err
|
||||
if err := checkYAMLDepth(&node, 0, maxDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
return &p, nil
|
||||
return node.Decode(out)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
if err := checkYAMLDepth(child, depth+1, maxDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
The `goccy/go-yaml` library since v1.16.0 limits nesting depth by default.
|
||||
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
|
||||
|
||||
|
||||
+115
-15
@@ -1,10 +1,12 @@
|
||||
package review
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -14,6 +16,18 @@ import (
|
||||
//go:embed personas/*.yaml
|
||||
var embeddedPersonas embed.FS
|
||||
|
||||
// MaxPersonaFileSize is the maximum size for persona files (64 KB).
|
||||
// This prevents denial-of-service via excessively large files.
|
||||
const MaxPersonaFileSize = 64 * 1024
|
||||
|
||||
// MaxYAMLDepth is the maximum nesting depth allowed in YAML persona files.
|
||||
// This prevents stack exhaustion from deeply nested structures.
|
||||
const MaxYAMLDepth = 20
|
||||
|
||||
// MaxYAMLNodes is the maximum number of YAML nodes allowed in persona files.
|
||||
// This prevents DoS via wide-but-shallow structures that bypass depth limits.
|
||||
const MaxYAMLNodes = 1000
|
||||
|
||||
// Persona defines a specialized review role with focused expertise.
|
||||
type Persona struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
@@ -36,36 +50,50 @@ type Severity struct {
|
||||
|
||||
// LoadPersona loads a persona from a JSON or YAML file path.
|
||||
// Format is detected by file extension: .yaml/.yml for YAML, .json or other for JSON.
|
||||
// Files larger than MaxPersonaFileSize are rejected.
|
||||
//
|
||||
// Symlinks are supported: os.Stat follows symlinks, so a symlink pointing to
|
||||
// a regular file will pass the IsRegular() check. Symlinks to non-regular files
|
||||
// (directories, FIFOs, devices) are still rejected.
|
||||
func LoadPersona(path string) (*Persona, error) {
|
||||
// os.Stat follows symlinks, so symlinks to regular files are supported.
|
||||
// The IsRegular() check operates on the target, not the symlink itself.
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
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 {
|
||||
return nil, fmt.Errorf("persona file %s exceeds maximum size (%d bytes)", path, MaxPersonaFileSize)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// LoadBuiltinPersona loads a built-in persona by name.
|
||||
// Returns an error if the persona doesn't exist.
|
||||
// Built-in personas are stored in YAML format.
|
||||
// Built-in personas are stored in YAML format only (see embed directive).
|
||||
func LoadBuiltinPersona(name string) (*Persona, error) {
|
||||
// Try YAML first (preferred format)
|
||||
yamlFile := name + ".yaml"
|
||||
data, err := embeddedPersonas.ReadFile("personas/" + yamlFile)
|
||||
if err == nil {
|
||||
return parsePersona(data, "builtin:"+yamlFile)
|
||||
}
|
||||
|
||||
// Fall back to JSON for backwards compatibility
|
||||
jsonFile := name + ".json"
|
||||
data, err = embeddedPersonas.ReadFile("personas/" + jsonFile)
|
||||
if err != nil {
|
||||
available := ListBuiltinPersonas()
|
||||
return nil, fmt.Errorf("unknown built-in persona %q (available: %s)", name, strings.Join(available, ", "))
|
||||
}
|
||||
return parsePersona(data, "builtin:"+jsonFile)
|
||||
return parsePersona(data, "builtin:"+yamlFile)
|
||||
}
|
||||
|
||||
// ListBuiltinPersonas returns the names of all built-in personas.
|
||||
// ListBuiltinPersonas returns the names of all built-in personas in sorted order.
|
||||
// Returns an empty slice if the embedded directory cannot be read.
|
||||
func ListBuiltinPersonas() []string {
|
||||
entries, err := embeddedPersonas.ReadDir("personas")
|
||||
@@ -94,10 +122,11 @@ func ListBuiltinPersonas() []string {
|
||||
seen[personaName] = true
|
||||
}
|
||||
}
|
||||
var names []string
|
||||
names := make([]string, 0, len(seen))
|
||||
for name := range seen {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -110,10 +139,13 @@ func parsePersona(data []byte, source string) (*Persona, error) {
|
||||
var p Persona
|
||||
var err error
|
||||
if isYAML {
|
||||
// go-yaml v1.16.0+ has built-in protection against deeply nested structures
|
||||
err = yaml.Unmarshal(data, &p)
|
||||
err = unmarshalYAMLWithDepthLimit(data, &p, MaxYAMLDepth)
|
||||
} else {
|
||||
err = json.Unmarshal(data, &p)
|
||||
// Use json.Decoder with DisallowUnknownFields for consistency with
|
||||
// YAML's KnownFields(true) - both reject unknown fields to catch typos.
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
err = dec.Decode(&p)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse persona %s: %w", source, err)
|
||||
@@ -124,6 +156,74 @@ func parsePersona(data []byte, source string) (*Persona, error) {
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// unmarshalYAMLWithDepthLimit unmarshals YAML data with explicit depth limiting
|
||||
// and strict field checking. This protects against stack exhaustion from deeply
|
||||
// nested structures and catches typos in field names.
|
||||
// Multi-document YAML files are rejected to prevent silent data loss.
|
||||
func unmarshalYAMLWithDepthLimit(data []byte, out any, maxDepth int) error {
|
||||
// First pass: decode into a yaml.Node to check depth limits and node counts.
|
||||
// This prevents stack exhaustion before we attempt to decode into structs.
|
||||
var node yaml.Node
|
||||
dec := yaml.NewDecoder(bytes.NewReader(data))
|
||||
if err := dec.Decode(&node); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reject multi-document YAML files - silently ignoring additional documents
|
||||
// could lead to confusing behavior where users think their changes take effect.
|
||||
var extra yaml.Node
|
||||
if dec.Decode(&extra) == nil {
|
||||
return fmt.Errorf("multi-document YAML is not supported; only single-document files are allowed")
|
||||
}
|
||||
|
||||
nodeCount := 0
|
||||
if err := checkYAMLDepth(&node, 0, maxDepth, MaxYAMLNodes, make(map[*yaml.Node]struct{}), &nodeCount); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Second pass: decode with strict field checking enabled.
|
||||
// KnownFields(true) rejects unknown keys, catching typos like "focuss" or "identiy".
|
||||
// We must re-decode from the original data because yaml.Node.Decode() doesn't
|
||||
// support the KnownFields option.
|
||||
strictDec := yaml.NewDecoder(bytes.NewReader(data))
|
||||
strictDec.KnownFields(true)
|
||||
return strictDec.Decode(out)
|
||||
}
|
||||
|
||||
// checkYAMLDepth recursively checks that YAML nodes don't exceed the depth limit
|
||||
// or the total node count limit. It also detects alias cycles to prevent infinite
|
||||
// recursion from crafted YAML with self-referential aliases.
|
||||
func checkYAMLDepth(node *yaml.Node, depth, maxDepth, maxNodes int, seen map[*yaml.Node]struct{}, nodeCount *int) error {
|
||||
if depth > maxDepth {
|
||||
return fmt.Errorf("YAML nesting depth exceeds maximum (%d)", maxDepth)
|
||||
}
|
||||
|
||||
// Track total nodes visited as defense-in-depth against wide-but-shallow attacks.
|
||||
*nodeCount++
|
||||
if *nodeCount > maxNodes {
|
||||
return fmt.Errorf("YAML node count exceeds maximum (%d)", maxNodes)
|
||||
}
|
||||
|
||||
// Cycle detection: if we've seen this node before, we're in a cycle.
|
||||
if _, ok := seen[node]; ok {
|
||||
return nil // Already validated this subtree, skip to avoid infinite recursion.
|
||||
}
|
||||
seen[node] = struct{}{}
|
||||
|
||||
// Handle alias nodes: follow the alias to its anchor target.
|
||||
// Increment depth when following aliases since they expand the effective structure.
|
||||
if node.Kind == yaml.AliasNode && node.Alias != nil {
|
||||
return checkYAMLDepth(node.Alias, depth+1, maxDepth, maxNodes, seen, nodeCount)
|
||||
}
|
||||
|
||||
for _, child := range node.Content {
|
||||
if err := checkYAMLDepth(child, depth+1, maxDepth, maxNodes, seen, nodeCount); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePersona(p *Persona, source string) error {
|
||||
if p.Name == "" {
|
||||
return fmt.Errorf("persona %s: name is required", source)
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package review
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestLoadBuiltinPersona(t *testing.T) {
|
||||
@@ -173,6 +176,7 @@ func TestLoadPersonaFromJSONFile(t *testing.T) {
|
||||
"display_name": "Test Persona",
|
||||
"identity": "You are a test persona.\nMulti-line identity works.",
|
||||
"focus": ["testing", "validation"],
|
||||
|
||||
"ignore": ["nothing"],
|
||||
"severity": {
|
||||
"major": "Big problems",
|
||||
@@ -449,3 +453,326 @@ severity:
|
||||
t.Errorf("Focus[0] = %q, want %q", p.Focus[0], "item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLDeeplyNestedRejection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "deeply-nested.yaml")
|
||||
|
||||
// Build a deeply nested YAML structure that exceeds MaxYAMLDepth (20).
|
||||
// Each level adds 2 to the depth count (key + value mapping).
|
||||
var sb strings.Builder
|
||||
sb.WriteString("name: test\nidentity: test\nnested:\n")
|
||||
indent := " "
|
||||
for i := 0; i < 25; i++ {
|
||||
sb.WriteString(strings.Repeat(indent, i+1))
|
||||
sb.WriteString(fmt.Sprintf("level%d:\n", i))
|
||||
}
|
||||
sb.WriteString(strings.Repeat(indent, 26))
|
||||
sb.WriteString("value: too-deep\n")
|
||||
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadPersona(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for deeply nested YAML, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "nesting depth exceeds") {
|
||||
t.Errorf("error = %q, want containing 'nesting depth exceeds'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLFileSizeLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "huge.yaml")
|
||||
|
||||
// Create a file larger than MaxPersonaFileSize (64 KB)
|
||||
content := "name: test\nidentity: " + strings.Repeat("x", MaxPersonaFileSize+1) + "\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadPersona(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for oversized file, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "exceeds maximum size") {
|
||||
t.Errorf("error = %q, want containing 'exceeds maximum size'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLAliasCycleDetection(t *testing.T) {
|
||||
// Test that our checkYAMLDepth function handles alias cycles gracefully
|
||||
// by using the seen map to prevent infinite recursion.
|
||||
// We test this directly because go-yaml's parser handles most cycles
|
||||
// at parse time, but we need to ensure our checker is robust.
|
||||
|
||||
// Create a node structure where an alias points to a parent node,
|
||||
// simulating what could happen with malicious input that bypasses
|
||||
// go-yaml's cycle detection.
|
||||
parent := &yaml.Node{
|
||||
Kind: yaml.MappingNode,
|
||||
Content: []*yaml.Node{
|
||||
{Kind: yaml.ScalarNode, Value: "name"},
|
||||
{Kind: yaml.ScalarNode, Value: "test"},
|
||||
{Kind: yaml.ScalarNode, Value: "nested"},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a child that aliases back to the parent (artificial cycle)
|
||||
aliasToParent := &yaml.Node{
|
||||
Kind: yaml.AliasNode,
|
||||
Alias: parent,
|
||||
}
|
||||
parent.Content = append(parent.Content, aliasToParent)
|
||||
|
||||
nodeCount := 0
|
||||
seen := make(map[*yaml.Node]struct{})
|
||||
|
||||
// This should NOT hang or stack overflow - the seen map prevents infinite recursion
|
||||
err := checkYAMLDepth(parent, 0, MaxYAMLDepth, MaxYAMLNodes, seen, &nodeCount)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error traversing cyclic structure: %v", err)
|
||||
}
|
||||
|
||||
// Verify we tracked the parent in the seen map
|
||||
if _, ok := seen[parent]; !ok {
|
||||
t.Error("parent node not tracked in seen map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLMultiDocumentRejection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "multi.yaml")
|
||||
|
||||
// Multi-document YAML (documents separated by ---)
|
||||
content := `name: first
|
||||
identity: first document
|
||||
---
|
||||
name: second
|
||||
identity: second document
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadPersona(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for multi-document YAML, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "multi-document") {
|
||||
t.Errorf("error = %q, want containing 'multi-document'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLNodeCountLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "wide.yaml")
|
||||
|
||||
// Build a YAML structure that's shallow but wide - many keys at the same level
|
||||
// to test the node count limit (should exceed MaxYAMLNodes = 1000)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("name: test\nidentity: test\n")
|
||||
for i := 0; i < 600; i++ {
|
||||
sb.WriteString(fmt.Sprintf("key%d: value%d\n", i, i))
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadPersona(path)
|
||||
if err == nil {
|
||||
t.Error("expected error for wide YAML exceeding node count, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "node count exceeds") {
|
||||
t.Errorf("error = %q, want containing 'node count exceeds'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckYAMLDepthCycleDetectionDirect(t *testing.T) {
|
||||
// Direct test of cycle detection in checkYAMLDepth by creating
|
||||
// a node structure with an artificial cycle.
|
||||
// This tests the seen map logic independent of go-yaml's parsing.
|
||||
node := &yaml.Node{
|
||||
Kind: yaml.MappingNode,
|
||||
Content: []*yaml.Node{
|
||||
{Kind: yaml.ScalarNode, Value: "key"},
|
||||
{Kind: yaml.ScalarNode, Value: "value"},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a cycle by making a child reference the parent
|
||||
cycleChild := &yaml.Node{
|
||||
Kind: yaml.AliasNode,
|
||||
Alias: node, // Points back to the parent
|
||||
}
|
||||
node.Content = append(node.Content,
|
||||
&yaml.Node{Kind: yaml.ScalarNode, Value: "cyclic"},
|
||||
cycleChild,
|
||||
)
|
||||
|
||||
nodeCount := 0
|
||||
seen := make(map[*yaml.Node]struct{})
|
||||
err := checkYAMLDepth(node, 0, MaxYAMLDepth, MaxYAMLNodes, seen, &nodeCount)
|
||||
|
||||
// Should complete without infinite recursion due to cycle detection
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
// The seen map should contain multiple entries
|
||||
if len(seen) < 2 {
|
||||
t.Errorf("seen map has %d entries, expected at least 2", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListBuiltinPersonasSortedOrder(t *testing.T) {
|
||||
names := ListBuiltinPersonas()
|
||||
if len(names) < 2 {
|
||||
t.Skip("need at least 2 personas to test ordering")
|
||||
}
|
||||
|
||||
// Verify the list is sorted
|
||||
for i := 1; i < len(names); i++ {
|
||||
if names[i-1] > names[i] {
|
||||
t.Errorf("ListBuiltinPersonas not sorted: %q > %q", names[i-1], names[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestYAMLUnknownFieldsRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unknown top-level field",
|
||||
content: `name: test
|
||||
identity: test identity
|
||||
unknown_field: should fail
|
||||
`,
|
||||
wantErr: "unknown_field",
|
||||
},
|
||||
{
|
||||
name: "typo in field name",
|
||||
content: `name: test
|
||||
identiy: typo should fail
|
||||
`,
|
||||
wantErr: "identiy",
|
||||
},
|
||||
{
|
||||
name: "unknown field in severity",
|
||||
content: `name: test
|
||||
identity: test
|
||||
severity:
|
||||
major: Major
|
||||
minro: typo
|
||||
`,
|
||||
wantErr: "minro",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "unknown.yaml")
|
||||
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadPersona(path)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for unknown field %q, got nil", tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONUnknownFieldsRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "unknown top-level field",
|
||||
content: `{
|
||||
"name": "test",
|
||||
"identity": "test identity",
|
||||
"unknown_field": "should fail"
|
||||
}`,
|
||||
wantErr: "unknown_field",
|
||||
},
|
||||
{
|
||||
name: "typo in field name",
|
||||
content: `{
|
||||
"name": "test",
|
||||
"identiy": "typo should fail"
|
||||
}`,
|
||||
wantErr: "identiy",
|
||||
},
|
||||
{
|
||||
name: "unknown field in severity",
|
||||
content: `{
|
||||
"name": "test",
|
||||
"identity": "test",
|
||||
"severity": {
|
||||
"major": "ok",
|
||||
"miner": "typo"
|
||||
}
|
||||
}`,
|
||||
wantErr: "miner",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.json")
|
||||
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
_, err := LoadPersona(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown field, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error = %q, want to contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPersonaSymlink(t *testing.T) {
|
||||
// Create a regular persona file
|
||||
dir := t.TempDir()
|
||||
realFile := filepath.Join(dir, "real.yaml")
|
||||
content := `name: test
|
||||
identity: test identity
|
||||
`
|
||||
if err := os.WriteFile(realFile, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("failed to write test file: %v", err)
|
||||
}
|
||||
|
||||
// Create a symlink to it
|
||||
symlink := filepath.Join(dir, "link.yaml")
|
||||
if err := os.Symlink(realFile, symlink); err != nil {
|
||||
t.Fatalf("failed to create symlink: %v", err)
|
||||
}
|
||||
|
||||
// LoadPersona should work via symlink
|
||||
p, err := LoadPersona(symlink)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPersona via symlink failed: %v", err)
|
||||
}
|
||||
if p.Name != "test" {
|
||||
t.Errorf("Name = %q, want %q", p.Name, "test")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user