Files
3x-ui/internal/sub/clash_yaml_test.go
T
Sanaei 7fe9932d7b fix(sub): quote Clash scalars a YAML parser would read as numbers (#6104)
A REALITY short-id like 2351e1 is valid hex, but as a bare YAML scalar the
resolution rules read it as the float 23510. mihomo hex-decodes the resulting
five-digit string, fails with "invalid REALITY short ID", and the whole
provider loads zero nodes — one proxy takes the entire subscription down.

The encoder quotes the forms it recognises (plain integers, hex, booleans)
but not the exponent-float form, and its own parser reads that token back as
a string, so nothing in a round-trip through it reveals the problem. Check
the values against the resolution rules instead, and force quotes on any
plain scalar that would resolve to a non-string.

Applied to every string in the document rather than to short-id alone: the
panel's own short-id generator emits random hex, and passwords, obfs-
passwords and pre-shared keys reach the output the same way. Unambiguous
values are untouched, so the document is otherwise byte-identical.

The existing Clash tests assert on the config map, never on the serialized
text, which is why this survived; the new tests assert on the output.
2026-07-27 14:28:34 +02:00

142 lines
4.5 KiB
Go

package sub
import (
"strings"
"testing"
"github.com/goccy/go-yaml"
)
// The encoder's own parser reads every one of these back as a string, so a
// round-trip through it proves nothing. What matters is the emitted text: a
// plain scalar that the YAML resolution rules turn into a number, bool, null
// or date is what breaks a Clash core, so those must carry quotes.
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)
}
// Whatever the quoting decision, the document must still parse.
var decoded map[string]any
if err := yaml.Unmarshal(out, &decoded); err != nil {
t.Fatalf("output must stay parseable: %v\n%s", err, out)
}
})
}
}
// The value in the report: unquoted, YAML reads 2351e1 as 23510, mihomo hex-
// decodes an odd-length "23510" and the provider drops to zero nodes (#6104).
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)
}
}
// A password or pre-shared key of the same shape reaches the output the same
// way, so the guard is not specific to short-id.
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)
}
}
}
// Values that are unambiguous must not pick up noise quoting, so the emitted
// document stays byte-identical to what the encoder produced before.
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)
}
}
// A quote inside the value must not terminate the scalar.
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)
}
}