diff --git a/internal/sub/clash_yaml.go b/internal/sub/clash_yaml.go index 99b067d9b..cf411d499 100644 --- a/internal/sub/clash_yaml.go +++ b/internal/sub/clash_yaml.go @@ -3,7 +3,10 @@ package sub import ( "reflect" "regexp" + "strconv" "strings" + "unicode" + "unicode/utf8" "github.com/goccy/go-yaml" ) @@ -41,30 +44,66 @@ func yamlScalarIsAmbiguous(s string) bool { 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 { if v == nil { return nil } if s, ok := v.(string); ok { - if yamlScalarIsAmbiguous(s) { - return yamlQuotedString(s) - } - return s + return quoteYAMLString(s) } rv := reflect.ValueOf(v) switch rv.Kind() { case reflect.Map: out := make(map[string]any, rv.Len()) + escapedKeys := false iter := rv.MapRange() for iter.Next() { key, ok := iter.Key().Interface().(string) if !ok { return v } + escapedKeys = escapedKeys || yamlNeedsEscaping(key) 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: if rv.Kind() == reflect.Slice && rv.Type().Elem().Kind() == reflect.Uint8 { return v diff --git a/internal/sub/clash_yaml_test.go b/internal/sub/clash_yaml_test.go index 710fc01f9..7a8850282 100644 --- a/internal/sub/clash_yaml_test.go +++ b/internal/sub/clash_yaml_test.go @@ -127,3 +127,32 @@ func TestQuotedScalarEscapesQuotes(t *testing.T) { 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) + } + } +}