feat: migrate to golang
This commit is contained in:
82
internal/yamlutil/yamlutil.go
Normal file
82
internal/yamlutil/yamlutil.go
Normal file
@ -0,0 +1,82 @@
|
||||
// Package yamlutil wraps gopkg.in/yaml.v3 unmarshaling to tolerate duplicate
|
||||
// mapping keys.
|
||||
//
|
||||
// yaml.v3 treats a duplicate key in the same mapping as a hard error
|
||||
// ("mapping key ... already defined at line ..."), whereas PyYAML (used by
|
||||
// the original Python implementation) silently accepts it, with the last
|
||||
// occurrence's value winning. Real-world mihomo/clash configs — including,
|
||||
// ironically, this project's own bundled default-core-config.yaml before it
|
||||
// was fixed — are frequently hand-edited/merged from multiple sources and do
|
||||
// contain duplicate keys. Unmarshal here supports both: well-formed YAML
|
||||
// decodes exactly as gopkg.in/yaml.v3 would, and YAML with duplicate keys
|
||||
// decodes using last-value-wins instead of erroring.
|
||||
package yamlutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Unmarshal decodes YAML data into out, tolerating duplicate mapping keys
|
||||
// (last one wins), unlike yaml.Unmarshal.
|
||||
func Unmarshal(data []byte, out interface{}) error {
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal(data, &doc); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(doc.Content) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
root := doc.Content[0]
|
||||
dedupeMappingKeys(root)
|
||||
return root.Decode(out)
|
||||
}
|
||||
|
||||
// dedupeMappingKeys walks the node tree, collapsing duplicate keys within
|
||||
// each mapping so that only the last occurrence of each key survives (with
|
||||
// its value), matching PyYAML/CPython dict-construction semantics. Recurses
|
||||
// into sequences and mapping values so nested duplicates are handled too.
|
||||
func dedupeMappingKeys(n *yaml.Node) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch n.Kind {
|
||||
case yaml.DocumentNode, yaml.SequenceNode:
|
||||
for _, c := range n.Content {
|
||||
dedupeMappingKeys(c)
|
||||
}
|
||||
|
||||
case yaml.MappingNode:
|
||||
type pair struct {
|
||||
key *yaml.Node
|
||||
value *yaml.Node
|
||||
}
|
||||
|
||||
seen := make(map[string]int, len(n.Content)/2)
|
||||
pairs := make([]pair, 0, len(n.Content)/2)
|
||||
|
||||
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||
key := n.Content[i]
|
||||
value := n.Content[i+1]
|
||||
|
||||
// Matches yaml.v3's own duplicate-key comparison (Kind+Value).
|
||||
id := fmt.Sprintf("%d:%s", key.Kind, key.Value)
|
||||
if idx, ok := seen[id]; ok {
|
||||
pairs[idx].value = value
|
||||
} else {
|
||||
seen[id] = len(pairs)
|
||||
pairs = append(pairs, pair{key: key, value: value})
|
||||
}
|
||||
}
|
||||
|
||||
content := make([]*yaml.Node, 0, len(pairs)*2)
|
||||
for _, p := range pairs {
|
||||
dedupeMappingKeys(p.value)
|
||||
content = append(content, p.key, p.value)
|
||||
}
|
||||
n.Content = content
|
||||
}
|
||||
}
|
||||
103
internal/yamlutil/yamlutil_test.go
Normal file
103
internal/yamlutil/yamlutil_test.go
Normal file
@ -0,0 +1,103 @@
|
||||
package yamlutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestUnmarshal_NoDuplicates(t *testing.T) {
|
||||
data := []byte("a: 1\nb: 2\n")
|
||||
var out map[string]interface{}
|
||||
if err := Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if out["a"] != 1 || out["b"] != 2 {
|
||||
t.Fatalf("unexpected result: %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_DuplicateKeyLastWins(t *testing.T) {
|
||||
// Mirrors the real bug: a duplicate top-level key that gopkg.in/yaml.v3
|
||||
// rejects outright, but PyYAML (and this package) tolerate by letting
|
||||
// the later occurrence win.
|
||||
data := []byte(`external-controller-cors:
|
||||
allow-origins:
|
||||
- "*"
|
||||
allow-private-network: true
|
||||
allow-origins:
|
||||
- tauri://localhost
|
||||
- https://yacd.metacubex.one
|
||||
`)
|
||||
|
||||
// Confirm plain yaml.Unmarshal actually fails on this input, i.e. that
|
||||
// the test is exercising the real bug.
|
||||
var control map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &control); err == nil {
|
||||
t.Fatalf("expected plain yaml.Unmarshal to fail on duplicate keys, it didn't")
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
cors, ok := out["external-controller-cors"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected external-controller-cors map, got %#v", out["external-controller-cors"])
|
||||
}
|
||||
|
||||
origins, ok := cors["allow-origins"].([]interface{})
|
||||
if !ok || len(origins) != 2 {
|
||||
t.Fatalf("expected last allow-origins (2 entries) to win, got %#v", cors["allow-origins"])
|
||||
}
|
||||
if origins[0] != "tauri://localhost" {
|
||||
t.Fatalf("expected last-occurrence value to win, got %#v", origins)
|
||||
}
|
||||
if cors["allow-private-network"] != true {
|
||||
t.Fatalf("expected sibling key to survive dedupe, got %#v", cors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_NestedDuplicates(t *testing.T) {
|
||||
data := []byte(`outer:
|
||||
inner:
|
||||
x: 1
|
||||
x: 2
|
||||
inner:
|
||||
y: 3
|
||||
`)
|
||||
var out map[string]interface{}
|
||||
if err := Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
outer := out["outer"].(map[string]interface{})
|
||||
inner := outer["inner"].(map[string]interface{})
|
||||
if _, hasX := inner["x"]; hasX {
|
||||
t.Fatalf("expected last 'inner' occurrence to fully replace the first, got %#v", inner)
|
||||
}
|
||||
if inner["y"] != 3 {
|
||||
t.Fatalf("expected inner.y == 3, got %#v", inner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_Empty(t *testing.T) {
|
||||
var out map[string]interface{}
|
||||
if err := Unmarshal([]byte(""), &out); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if out != nil {
|
||||
t.Fatalf("expected nil map for empty input, got %#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal_Sequence(t *testing.T) {
|
||||
data := []byte("- a: 1\n a: 2\n- b: 3\n")
|
||||
var out []map[string]interface{}
|
||||
if err := Unmarshal(data, &out); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(out) != 2 || out[0]["a"] != 2 || out[1]["b"] != 3 {
|
||||
t.Fatalf("unexpected result: %#v", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user