diff --git a/internal/proxy/format.go b/internal/proxy/format.go index 51c9df1..db22adf 100644 --- a/internal/proxy/format.go +++ b/internal/proxy/format.go @@ -127,7 +127,15 @@ func ParseYamlProxies(raw string) []model.ProxyNode { // ParseProxyLines splits raw text into lines, skips comments/blanks/section headers, // and parses each line as a URI or client config line. +// +// Literal "\n" / "\r\n" sequences (backslash + n, as produced by some frontends +// stuffing multi-line content into a single string) are normalized to real +// newlines before splitting so each link is parsed as its own line. func ParseProxyLines(raw string) []model.ProxyNode { + if strings.Contains(raw, `\n`) { + raw = strings.ReplaceAll(raw, `\r\n`, "\n") + raw = strings.ReplaceAll(raw, `\n`, "\n") + } lines := strings.Split(raw, "\n") var result []model.ProxyNode index := 0 diff --git a/internal/proxy/parser_test.go b/internal/proxy/parser_test.go index 67172ec..d78131d 100644 --- a/internal/proxy/parser_test.go +++ b/internal/proxy/parser_test.go @@ -290,3 +290,94 @@ func TestNodeJSONSerialization(t *testing.T) { t.Errorf("expected type ss after round-trip, got %v", back["type"]) } } + +func vlessURI(name string) string { + return "vless://uuid-1234@1.2.3.4:443?encryption=none&security=tls&type=ws&host=example.com&path=%2Fpath&sni=example.com#" + name +} + +// TestParseProxyLinesLiteralNewline reproduces the production case: content +// stored with literal "\n" (backslash + n) between links instead of real +// newlines. It must normalize to real newlines and parse every link. +func TestParseProxyLinesLiteralNewline(t *testing.T) { + raw := strings.Join([]string{ + vlessURI("ali-seoul"), + vlessURI("Ali-Tokyo"), + vlessURI("racknerd-la"), + }, `\n`) + + nodes := ParseProxies(raw) + if len(nodes) != 3 { + t.Fatalf("expected 3 nodes from literal-\\n separated links, got %d", len(nodes)) + } + wantNames := []string{"ali-seoul", "Ali-Tokyo", "racknerd-la"} + for i, want := range wantNames { + if got := nodes[i]["name"]; got != want { + t.Errorf("node %d: expected name %q, got %q", i, want, got) + } + } +} + +// TestParseProxyLinesLiteralCRLF normalizes literal "\\r\\n" sequences too. +func TestParseProxyLinesLiteralCRLF(t *testing.T) { + raw := strings.Join([]string{ + vlessURI("Node1"), + vlessURI("Node2"), + }, `\r\n`) + + nodes := ParseProxies(raw) + if len(nodes) != 2 { + t.Fatalf("expected 2 nodes from literal-\\r\\n separated links, got %d", len(nodes)) + } + if nodes[0]["name"] != "Node1" || nodes[1]["name"] != "Node2" { + t.Errorf("unexpected names: %v / %v", nodes[0]["name"], nodes[1]["name"]) + } +} + +// TestParseProxyLinesRealNewlineUnaffected: real newline input still parses +// the same after the normalization change. +func TestParseProxyLinesRealNewlineUnaffected(t *testing.T) { + raw := strings.Join([]string{ + vlessURI("Node1"), + "", + "# comment", + vlessURI("Node2"), + }, "\n") + + nodes := ParseProxies(raw) + if len(nodes) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(nodes)) + } + if nodes[0]["name"] != "Node1" || nodes[1]["name"] != "Node2" { + t.Errorf("unexpected names: %v / %v", nodes[0]["name"], nodes[1]["name"]) + } +} + +// TestParseProxyLinesSingleLinkUnchanged: a single link with no separators +// keeps its exact behavior (name untouched). +func TestParseProxyLinesSingleLinkUnchanged(t *testing.T) { + raw := vlessURI("solo") + nodes := ParseProxies(raw) + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + if nodes[0]["name"] != "solo" { + t.Errorf("expected name solo, got %v", nodes[0]["name"]) + } +} + +// TestDecodeMaybeBase64LiteralNewlinePassthrough: structured literal-\n content +// must pass through DecodeMaybeBase64 unchanged — normalization happens inside +// ParseProxyLines (after format/base64 detection, before splitting) and must +// not interfere with base64 detection. +func TestDecodeMaybeBase64LiteralNewlinePassthrough(t *testing.T) { + raw := strings.Join([]string{ + vlessURI("Node1"), + vlessURI("Node2"), + }, `\n`) + if got := DecodeMaybeBase64(raw); got != raw { + t.Error("structured literal-\\n content must pass through unchanged") + } + if nodes := ParseProxies(DecodeMaybeBase64(raw)); len(nodes) != 2 { + t.Fatalf("expected 2 nodes through full pipeline, got %d", len(nodes)) + } +}