629e29806c
- Add explicit case for *ast.MergeKeyNode in checkYAMLDepth switch to make it clear this is an intentional leaf (no children to recurse) rather than relying on the default case. Prevents future library changes from silently bypassing depth checks. - Add MaxPersonaFileSize bound check at the top of ParsePersonaBytes. While callers already check size, the public API should defend itself (defense in depth) against arbitrarily large inputs that could cause excessive memory/CPU before AST validation runs. - Add tests for both behaviors. Addresses review #2879 findings.
960 lines
25 KiB
Go
960 lines
25 KiB
Go
package review
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/goccy/go-yaml/ast"
|
|
)
|
|
|
|
func TestLoadBuiltinPersona(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
personaName string
|
|
wantErr bool
|
|
wantDisplay string
|
|
}{
|
|
{
|
|
name: "security persona",
|
|
personaName: "security",
|
|
wantErr: false,
|
|
wantDisplay: "Security Specialist",
|
|
},
|
|
{
|
|
name: "architect persona",
|
|
personaName: "architect",
|
|
wantErr: false,
|
|
wantDisplay: "Software Architect",
|
|
},
|
|
{
|
|
name: "docs persona",
|
|
personaName: "docs",
|
|
wantErr: false,
|
|
wantDisplay: "Documentation Reviewer",
|
|
},
|
|
{
|
|
name: "unknown persona",
|
|
personaName: "nonexistent",
|
|
wantErr: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
p, err := LoadBuiltinPersona(tt.personaName)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Error("expected error, got nil")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if p.Name != tt.personaName {
|
|
t.Errorf("Name = %q, want %q", p.Name, tt.personaName)
|
|
}
|
|
if p.DisplayName != tt.wantDisplay {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, tt.wantDisplay)
|
|
}
|
|
if p.Identity == "" {
|
|
t.Error("Identity should not be empty")
|
|
}
|
|
if len(p.Focus) == 0 {
|
|
t.Error("Focus should not be empty")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListBuiltinPersonas(t *testing.T) {
|
|
names := ListBuiltinPersonas()
|
|
if len(names) == 0 {
|
|
t.Fatal("expected at least one built-in persona")
|
|
}
|
|
|
|
// Check for expected personas
|
|
expected := map[string]bool{"security": false, "architect": false, "docs": false}
|
|
for _, name := range names {
|
|
if _, ok := expected[name]; ok {
|
|
expected[name] = true
|
|
}
|
|
}
|
|
for name, found := range expected {
|
|
if !found {
|
|
t.Errorf("expected built-in persona %q not found", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFromYAMLFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.yaml")
|
|
|
|
content := `# Test persona
|
|
name: test
|
|
display_name: Test Persona
|
|
identity: |
|
|
You are a test persona.
|
|
Multi-line identity works.
|
|
focus:
|
|
- testing
|
|
- validation
|
|
ignore:
|
|
- nothing
|
|
severity:
|
|
major: Big problems
|
|
minor: Small problems
|
|
nit: Tiny problems
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
if p.DisplayName != "Test Persona" {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test Persona")
|
|
}
|
|
if len(p.Focus) != 2 {
|
|
t.Errorf("Focus len = %d, want 2", len(p.Focus))
|
|
}
|
|
if !strings.Contains(p.Identity, "Multi-line") {
|
|
t.Error("Identity should contain multi-line content")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFromYMLFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.yml")
|
|
|
|
content := `name: test
|
|
display_name: Test YML
|
|
identity: Test identity
|
|
focus:
|
|
- testing
|
|
ignore: []
|
|
severity:
|
|
major: Big
|
|
minor: Small
|
|
nit: Tiny
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
if p.DisplayName != "Test YML" {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test YML")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFromJSONFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test.json")
|
|
|
|
content := `{
|
|
"name": "test",
|
|
"display_name": "Test Persona",
|
|
"identity": "You are a test persona.\nMulti-line identity works.",
|
|
"focus": ["testing", "validation"],
|
|
|
|
"ignore": ["nothing"],
|
|
"severity": {
|
|
"major": "Big problems",
|
|
"minor": "Small problems",
|
|
"nit": "Tiny problems"
|
|
}
|
|
}`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
if p.DisplayName != "Test Persona" {
|
|
t.Errorf("DisplayName = %q, want %q", p.DisplayName, "Test Persona")
|
|
}
|
|
if len(p.Focus) != 2 {
|
|
t.Errorf("Focus len = %d, want 2", len(p.Focus))
|
|
}
|
|
if !strings.Contains(p.Identity, "Multi-line") {
|
|
t.Error("Identity should contain multi-line content")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
ext string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "missing name yaml",
|
|
content: "identity: test\n",
|
|
ext: ".yaml",
|
|
wantErr: "name is required",
|
|
},
|
|
{
|
|
name: "missing identity yaml",
|
|
content: "name: test\n",
|
|
ext: ".yaml",
|
|
wantErr: "identity is required",
|
|
},
|
|
{
|
|
name: "missing name json",
|
|
content: `{"identity": "test"}`,
|
|
ext: ".json",
|
|
wantErr: "name is required",
|
|
},
|
|
{
|
|
name: "missing identity json",
|
|
content: `{"name": "test"}`,
|
|
ext: ".json",
|
|
wantErr: "identity is required",
|
|
},
|
|
{
|
|
name: "display_name defaults to name",
|
|
content: "name: test\nidentity: test identity\n",
|
|
ext: ".yaml",
|
|
// No error expected - should succeed
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test"+tt.ext)
|
|
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if tt.wantErr != "" {
|
|
if err == nil {
|
|
t.Errorf("expected error containing %q, got nil", tt.wantErr)
|
|
return
|
|
}
|
|
if !strings.Contains(err.Error(), tt.wantErr) {
|
|
t.Errorf("error = %q, want containing %q", err.Error(), tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Check display_name defaulting
|
|
if p.DisplayName == "" {
|
|
t.Error("DisplayName should default to Name")
|
|
}
|
|
if p.DisplayName != p.Name {
|
|
t.Errorf("DisplayName should default to Name, got %q", p.DisplayName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaFileNotFound(t *testing.T) {
|
|
_, err := LoadPersona("/nonexistent/path/persona.yaml")
|
|
if err == nil {
|
|
t.Error("expected error for nonexistent file")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaInvalidYAML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "invalid.yaml")
|
|
if err := os.WriteFile(path, []byte("not valid yaml:\n - [broken"), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for invalid YAML")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaInvalidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "invalid.json")
|
|
if err := os.WriteFile(path, []byte("not valid json {"), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for invalid JSON")
|
|
}
|
|
}
|
|
|
|
func TestLoadPersonaCaseInsensitiveExtension(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
ext string
|
|
}{
|
|
{"lowercase yaml", ".yaml"},
|
|
{"uppercase YAML", ".YAML"},
|
|
{"mixed case Yaml", ".Yaml"},
|
|
{"lowercase yml", ".yml"},
|
|
{"uppercase YML", ".YML"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "test"+tt.ext)
|
|
content := "name: test\nidentity: test identity\n"
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed for extension %s: %v", tt.ext, err)
|
|
}
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCapitalizeFirst(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{"hello", "Hello"},
|
|
{"Hello", "Hello"},
|
|
{"HELLO", "HELLO"},
|
|
{"a", "A"},
|
|
{"", ""},
|
|
{"日本語", "日本語"}, // Non-ASCII: Japanese doesn't have case
|
|
{"über", "Über"}, // German umlaut
|
|
{"élève", "Élève"}, // French accent
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.input, func(t *testing.T) {
|
|
got := CapitalizeFirst(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("CapitalizeFirst(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListBuiltinPersonasReturnsEmptySlice(t *testing.T) {
|
|
// ListBuiltinPersonas should return an empty slice (not nil) on error.
|
|
// We can't easily test the error case, but we can verify the success case
|
|
// returns a proper slice.
|
|
names := ListBuiltinPersonas()
|
|
if names == nil {
|
|
t.Error("ListBuiltinPersonas should return empty slice, not nil")
|
|
}
|
|
}
|
|
|
|
func TestYAMLMultilineStrings(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "multiline.yaml")
|
|
|
|
// Test literal block scalar (|) which preserves newlines
|
|
content := `name: multiline
|
|
display_name: Multiline Test
|
|
identity: |
|
|
First line.
|
|
Second line.
|
|
Third line.
|
|
focus:
|
|
- item one
|
|
ignore: []
|
|
severity:
|
|
major: Major issue
|
|
minor: Minor issue
|
|
nit: Nit
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
// Literal block scalar preserves newlines
|
|
if !strings.Contains(p.Identity, "\n") {
|
|
t.Error("Identity should contain newlines from literal block scalar")
|
|
}
|
|
if !strings.Contains(p.Identity, "Second line") {
|
|
t.Error("Identity should contain 'Second line'")
|
|
}
|
|
}
|
|
|
|
func TestYAMLComments(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "comments.yaml")
|
|
|
|
content := `# This is a comment
|
|
name: commented # inline comment
|
|
display_name: Commented Persona
|
|
# Another comment
|
|
identity: Test identity
|
|
focus:
|
|
- item # comment after item
|
|
ignore: []
|
|
severity:
|
|
major: Major
|
|
minor: Minor
|
|
nit: Nit
|
|
`
|
|
|
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
p, err := LoadPersona(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadPersona failed: %v", err)
|
|
}
|
|
|
|
// Comments should be ignored
|
|
if p.Name != "commented" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "commented")
|
|
}
|
|
if p.Focus[0] != "item" {
|
|
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).
|
|
// Depth accumulation trace for "nested: \n level0: \n level1: ...":
|
|
// - Document root parsed at depth 0
|
|
// - Root MappingNode children (MappingValueNodes) visited at depth 1
|
|
// - "nested" MappingValueNode: key at depth 2, value at depth 2
|
|
// - Each levelN adds depth via MappingValueNode traversal (key + value)
|
|
// - Exact depth per level depends on AST structure (MappingNode wrapping),
|
|
// but 25 levels reliably exceeds MaxYAMLDepth (20) with comfortable margin.
|
|
// The test uses 25 levels rather than exactly 21 to avoid brittleness.
|
|
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 TestYAMLEmptyFileRejection(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{"completely_empty", ""},
|
|
{"whitespace_only", " \n\n "},
|
|
{"comment_only", "# just a comment\n"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, tc.name+".yaml")
|
|
if err := os.WriteFile(path, []byte(tc.content), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
_, err := LoadPersona(path)
|
|
if err == nil {
|
|
t.Error("expected error for empty YAML input, got nil")
|
|
}
|
|
if err != nil && !strings.Contains(err.Error(), "empty YAML document") {
|
|
t.Errorf("expected error containing %q, got: %v", "empty YAML document", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 visiting map to prevent infinite recursion.
|
|
|
|
// Create a node structure where an alias points to a parent node,
|
|
// simulating what could happen with crafted input.
|
|
parent := &ast.MappingNode{
|
|
Values: []*ast.MappingValueNode{
|
|
{
|
|
Key: &ast.StringNode{Value: "name"},
|
|
Value: &ast.StringNode{Value: "test"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a child that aliases back to the parent (artificial cycle)
|
|
aliasToParent := &ast.AliasNode{
|
|
Value: parent,
|
|
}
|
|
parent.Values = append(parent.Values, &ast.MappingValueNode{
|
|
Key: &ast.StringNode{Value: "nested"},
|
|
Value: aliasToParent,
|
|
})
|
|
|
|
nodeCount := 0
|
|
validated := make(map[ast.Node]int)
|
|
visiting := make(map[ast.Node]bool)
|
|
|
|
// This should NOT hang or stack overflow - cycle detection prevents infinite recursion
|
|
err := checkYAMLDepth(parent, 0, MaxYAMLDepth, MaxYAMLNodes, validated, visiting, &nodeCount)
|
|
if err != nil {
|
|
t.Errorf("unexpected error traversing cyclic structure: %v", err)
|
|
}
|
|
|
|
// Verify we tracked the parent in the validated map
|
|
if _, ok := validated[parent]; !ok {
|
|
t.Error("parent node not tracked in validated 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.
|
|
node := &ast.MappingNode{
|
|
Values: []*ast.MappingValueNode{
|
|
{
|
|
Key: &ast.StringNode{Value: "key"},
|
|
Value: &ast.StringNode{Value: "value"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create a cycle by making a child reference the parent
|
|
cycleChild := &ast.AliasNode{
|
|
Value: node, // Points back to the parent
|
|
}
|
|
node.Values = append(node.Values, &ast.MappingValueNode{
|
|
Key: &ast.StringNode{Value: "cyclic"},
|
|
Value: cycleChild,
|
|
})
|
|
|
|
nodeCount := 0
|
|
validated := make(map[ast.Node]int)
|
|
visiting := make(map[ast.Node]bool)
|
|
err := checkYAMLDepth(node, 0, MaxYAMLDepth, MaxYAMLNodes, validated, visiting, &nodeCount)
|
|
|
|
// Should complete without infinite recursion due to cycle detection
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
// The validated map should contain multiple entries
|
|
if len(validated) < 2 {
|
|
t.Errorf("validated map has %d entries, expected at least 2", len(validated))
|
|
}
|
|
}
|
|
|
|
func TestYAMLAliasDepthBypass(t *testing.T) {
|
|
// Test that an anchored subtree first validated at a shallow depth is
|
|
// re-checked when referenced via alias at a deeper position. Without the
|
|
// depth-aware validated map, the alias reference would skip re-checking
|
|
// and allow the effective nesting to exceed MaxYAMLDepth.
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "alias-depth-bypass.yaml")
|
|
|
|
// Build YAML with an anchor at shallow depth containing a subtree near the limit,
|
|
// then reference it via alias deep enough that effective depth exceeds MaxYAMLDepth.
|
|
var sb strings.Builder
|
|
sb.WriteString("name: test\nidentity: test\n")
|
|
|
|
// Create the anchored subtree at depth 1 (key level) that nests 15 levels deep.
|
|
sb.WriteString("anchor_key: &deep_anchor\n")
|
|
for i := 0; i < 15; i++ {
|
|
sb.WriteString(strings.Repeat(" ", i+1))
|
|
sb.WriteString(fmt.Sprintf("level%d:\n", i))
|
|
}
|
|
sb.WriteString(strings.Repeat(" ", 16))
|
|
sb.WriteString("leaf: value\n")
|
|
|
|
// Create a wrapper that nests 6 levels deep, then references the anchor.
|
|
// Effective depth at alias target = 6 (wrapper nesting) + 1 (alias) + 15 (subtree) = 22 > 20
|
|
sb.WriteString("wrapper:\n")
|
|
for i := 0; i < 6; i++ {
|
|
sb.WriteString(strings.Repeat(" ", i+1))
|
|
sb.WriteString(fmt.Sprintf("n%d:\n", i))
|
|
}
|
|
sb.WriteString(strings.Repeat(" ", 7))
|
|
sb.WriteString("alias_ref: *deep_anchor\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.Fatal("expected error for alias depth bypass, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "nesting depth exceeds") {
|
|
t.Errorf("error = %q, want containing 'nesting depth exceeds'", err.Error())
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestJSONTrailingContentRejected(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{
|
|
name: "trailing garbage after object",
|
|
content: `{"name":"test","identity":"test identity"}garbage`,
|
|
},
|
|
{
|
|
name: "two JSON objects",
|
|
content: `{"name":"test","identity":"test identity"}{"name":"other"}`,
|
|
},
|
|
{
|
|
name: "trailing array",
|
|
content: `{"name":"test","identity":"test identity"}[]`,
|
|
},
|
|
}
|
|
|
|
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 trailing content, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "trailing content") {
|
|
t.Errorf("error = %q, want to contain 'trailing content'", err.Error())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParsePersonaBytesSizeLimit(t *testing.T) {
|
|
// ParsePersonaBytes should reject input exceeding MaxPersonaFileSize
|
|
oversized := make([]byte, MaxPersonaFileSize+1)
|
|
for i := range oversized {
|
|
oversized[i] = 'x'
|
|
}
|
|
|
|
_, err := ParsePersonaBytes(oversized, "oversized.yaml")
|
|
if err == nil {
|
|
t.Fatal("expected error for oversized input, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "exceeds maximum size") {
|
|
t.Errorf("error = %q, want to contain 'exceeds maximum size'", err.Error())
|
|
}
|
|
|
|
// Just under the limit should not trigger size error (may fail parse, but not size)
|
|
underLimit := []byte("name: test\nidentity: test persona\n")
|
|
p, err := ParsePersonaBytes(underLimit, "valid.yaml")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error for valid input: %v", err)
|
|
}
|
|
if p.Name != "test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "test")
|
|
}
|
|
}
|
|
|
|
func TestYAMLMergeKeyDepthCheck(t *testing.T) {
|
|
// Verify that YAML merge keys (<<: *alias) are properly handled by the
|
|
// depth checker. The merge key content is in the MappingValueNode.Value
|
|
// (an AliasNode), not in the MergeKeyNode itself.
|
|
p, err := ParsePersonaBytes([]byte("name: merge-test\nidentity: test\n"), "merge.yaml")
|
|
if err != nil {
|
|
t.Fatalf("basic parse failed: %v", err)
|
|
}
|
|
if p.Name != "merge-test" {
|
|
t.Errorf("Name = %q, want %q", p.Name, "merge-test")
|
|
}
|
|
|
|
// Test that deeply nested merge keys still hit depth limit.
|
|
// Build YAML with merge key content nested beyond MaxYAMLDepth.
|
|
var sb strings.Builder
|
|
sb.WriteString("name: deep-merge\nidentity: deep merge persona\n")
|
|
sb.WriteString("anchor: &deep\n")
|
|
indent := " "
|
|
for i := 0; i < MaxYAMLDepth+5; i++ {
|
|
sb.WriteString(indent)
|
|
sb.WriteString(fmt.Sprintf("level%d:\n", i))
|
|
indent += " "
|
|
}
|
|
sb.WriteString(indent + "leaf: value\n")
|
|
sb.WriteString("target:\n <<: *deep\n")
|
|
|
|
_, err = ParsePersonaBytes([]byte(sb.String()), "deep-merge.yaml")
|
|
if err == nil {
|
|
t.Fatal("expected error for deeply nested merge key content, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "depth") {
|
|
t.Errorf("error = %q, want to contain 'depth'", err.Error())
|
|
}
|
|
}
|