mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-11 07:34:59 +03:00
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.
This commit is contained in:
@@ -3,7 +3,10 @@ package sub
|
|||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
)
|
)
|
||||||
@@ -41,30 +44,66 @@ func yamlScalarIsAmbiguous(s string) bool {
|
|||||||
return yamlNonStringWords[s] || yamlNonStringNumber.MatchString(s)
|
return yamlNonStringWords[s] || yamlNonStringNumber.MatchString(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// yamlEscapedString is written double-quoted on one line: the encoder's block scalars
|
||||||
|
// mishandle CR and the Unicode line breaks, so the text could continue as YAML.
|
||||||
|
type yamlEscapedString string
|
||||||
|
|
||||||
|
func (s yamlEscapedString) MarshalYAML() ([]byte, error) {
|
||||||
|
return []byte(strconv.Quote(string(s))), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func yamlNeedsEscaping(s string) bool {
|
||||||
|
if !utf8.ValidString(s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.ContainsFunc(s, func(r rune) bool { return !unicode.IsPrint(r) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteYAMLString(s string) any {
|
||||||
|
switch {
|
||||||
|
case yamlNeedsEscaping(s):
|
||||||
|
return yamlEscapedString(s)
|
||||||
|
case yamlScalarIsAmbiguous(s):
|
||||||
|
return yamlQuotedString(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func quoteAmbiguousYAMLScalars(v any) any {
|
func quoteAmbiguousYAMLScalars(v any) any {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if s, ok := v.(string); ok {
|
if s, ok := v.(string); ok {
|
||||||
if yamlScalarIsAmbiguous(s) {
|
return quoteYAMLString(s)
|
||||||
return yamlQuotedString(s)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rv := reflect.ValueOf(v)
|
rv := reflect.ValueOf(v)
|
||||||
switch rv.Kind() {
|
switch rv.Kind() {
|
||||||
case reflect.Map:
|
case reflect.Map:
|
||||||
out := make(map[string]any, rv.Len())
|
out := make(map[string]any, rv.Len())
|
||||||
|
escapedKeys := false
|
||||||
iter := rv.MapRange()
|
iter := rv.MapRange()
|
||||||
for iter.Next() {
|
for iter.Next() {
|
||||||
key, ok := iter.Key().Interface().(string)
|
key, ok := iter.Key().Interface().(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
escapedKeys = escapedKeys || yamlNeedsEscaping(key)
|
||||||
out[key] = quoteAmbiguousYAMLScalars(iter.Value().Interface())
|
out[key] = quoteAmbiguousYAMLScalars(iter.Value().Interface())
|
||||||
}
|
}
|
||||||
return out
|
if !escapedKeys {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// Only marshaler-typed keys escape, and only a map[any]any key keeps its type.
|
||||||
|
keyed := make(map[any]any, len(out))
|
||||||
|
for key, value := range out {
|
||||||
|
if yamlNeedsEscaping(key) {
|
||||||
|
keyed[yamlEscapedString(key)] = value
|
||||||
|
} else {
|
||||||
|
keyed[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keyed
|
||||||
case reflect.Slice, reflect.Array:
|
case reflect.Slice, reflect.Array:
|
||||||
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 {
|
if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -127,3 +127,32 @@ func TestQuotedScalarEscapesQuotes(t *testing.T) {
|
|||||||
t.Errorf("password round-tripped to %#v\n%s", got, out)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user