Files
3x-ui/internal/sub/clash_yaml_test.go
T
MHSanaei 8359a0f72e fix(sub): keep every Clash scalar on its own line
The YAML encoder writes multi-line strings as block scalars and gets
CR and the Unicode line breaks (NEL, LS, PS) wrong inside them, so a
remark, path or header name with such characters could continue as YAML
outside its scalar. Strings with a non-printable rune or invalid UTF-8
are now written double-quoted with escapes, map keys included.
2026-10-31 08:56:24 +00:00

159 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sub
import (
"strings"
"testing"
"github.com/goccy/go-yaml"
)
func TestAmbiguousScalarsAreQuoted(t *testing.T) {
tests := []struct {
name string
value string
mustQuote bool
}{
{"reality short-id read as a float", "2351e1", true},
{"short exponent form", "0e1", true},
{"long exponent form", "12e34", true},
{"all digits", "123456", true},
{"leading zeros read as octal", "0177", true},
{"hex form", "0x1f", true},
{"decimal point", "1.5", true},
{"boolean word", "true", true},
{"single letter bool", "y", true},
{"null word", "null", true},
{"tilde", "~", true},
{"date", "2026-07-27", true},
{"sexagesimal", "12:30", true},
{"plain hex with letters", "6ba7b8", false},
{"letters only", "abcdef", false},
{"hostname", "example.com", false},
{"dotted quad", "1.2.3.4", false},
{"uuid", "6ba7b810-9dad-11d1-80b4-00c04fd430c8", false},
{"alpn token", "h2", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"reality-opts": map[string]any{"short-id": tt.value},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
got := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(string(out)), "reality-opts:"))
quoted := strings.Contains(got, "'"+tt.value+"'") || strings.Contains(got, `"`+tt.value+`"`)
if tt.mustQuote && !quoted {
t.Errorf("%q must be emitted quoted, got %q", tt.value, got)
}
if !tt.mustQuote && quoted {
t.Errorf("%q needs no quoting, got %q", tt.value, got)
}
var decoded map[string]any
if err := yaml.Unmarshal(out, &decoded); err != nil {
t.Fatalf("output must stay parseable: %v\n%s", err, out)
}
})
}
}
func TestClashShortIDIsQuotedInOutput(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"proxies": []any{map[string]any{
"name": "de-1",
"reality-opts": map[string]any{"short-id": "2351e1"},
}},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
if !strings.Contains(string(out), `'2351e1'`) {
t.Errorf("short-id must be emitted as a quoted scalar, got:\n%s", out)
}
}
func TestAmbiguousPasswordIsQuoted(t *testing.T) {
out, err := marshalClashYAML(map[string]any{
"proxies": []any{map[string]any{
"name": "de-1",
"password": "80e12",
"obfs-password": "12345",
"pre-shared-key": "9e9",
}},
})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
for _, want := range []string{`'80e12'`, `'12345'`, `'9e9'`} {
if !strings.Contains(string(out), want) {
t.Errorf("expected %s in output:\n%s", want, out)
}
}
}
func TestUnambiguousScalarsAreUnchanged(t *testing.T) {
config := map[string]any{
"port": 7890,
"mode": "rule",
"proxies": []any{map[string]any{"name": "de-1", "udp": true, "port": 443}},
"rules": []string{"MATCH,PROXY"},
"alpn": []string{"h2", "http/1.1"},
"nonempty": "example.com",
}
quoted, err := marshalClashYAML(config)
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
plain, err := yaml.Marshal(config)
if err != nil {
t.Fatalf("yaml.Marshal: %v", err)
}
if string(quoted) != string(plain) {
t.Errorf("unambiguous document changed:\n--- got ---\n%s\n--- want ---\n%s", quoted, plain)
}
}
func TestQuotedScalarEscapesQuotes(t *testing.T) {
out, err := marshalClashYAML(map[string]any{"password": "1e2'3"})
if err != nil {
t.Fatalf("marshalClashYAML: %v", err)
}
var decoded map[string]any
if err := yaml.Unmarshal(out, &decoded); err != nil {
t.Fatalf("output must stay parseable: %v\n%s", err, out)
}
if got := decoded["password"]; got != any("1e2'3") {
t.Errorf("password round-tripped to %#v\n%s", got, out)
}
}
// Node-reported paths, remarks and header names reach the profile; a line break the
// encoder writes bare would start a top-level key of the subscriber's Clash config.
func TestLineBreaksStayInsideTheirScalar(t *testing.T) {
for _, value := range []string{
"/ws\r\n\r\nmode:\r direct", "/ws\n\n\n\n\n\n\rmode:\r direct", "a\nb\nc\u0085mode: direct",
"a\nb\nc
mode: direct", "a\nb\nc
mode: direct", "2026-01-01\n\n\rmode: direct",
} {
profile := map[string]any{"proxies": []any{map[string]any{
"name": "n",
"ws-opts": map[string]any{"path": value, "headers": map[string]any{value: "v"}},
}}}
out, err := marshalClashYAML(profile)
if err != nil {
t.Fatalf("marshal %q: %v", value, err)
}
if strings.ContainsAny(string(out), "\r\u0085

") || strings.Contains(string(out), "\nmode:") {
t.Fatalf("value %q escaped its scalar:\n%q", value, out)
}
var back map[string]any
if err := yaml.Unmarshal(out, &back); err != nil {
t.Fatalf("read back %q: %v\n%q", value, err, out)
}
opts := back["proxies"].([]any)[0].(map[string]any)["ws-opts"].(map[string]any)
if opts["path"] != value || opts["headers"].(map[string]any)[value] != "v" {
t.Fatalf("value %q did not round-trip:\n%q", value, out)
}
}
}