diff --git a/v2rayN/ServiceLib.Tests/Fmt/FmtHandlerTests.cs b/v2rayN/ServiceLib.Tests/Fmt/FmtHandlerTests.cs
index bca21685..29a5d574 100644
--- a/v2rayN/ServiceLib.Tests/Fmt/FmtHandlerTests.cs
+++ b/v2rayN/ServiceLib.Tests/Fmt/FmtHandlerTests.cs
@@ -2,6 +2,46 @@ namespace ServiceLib.Tests.Fmt;
public class FmtHandlerTests
{
+ ///
+ /// One profile factory per protocol that can export.
+ /// The suite below asserts that this map and agree, so a
+ /// newly exportable protocol cannot be added without a round-trip case.
+ ///
+ private static readonly Dictionary> ShareProfileFactories = new()
+ {
+ [EConfigType.VMess] = CreateVmessProfile,
+ [EConfigType.Shadowsocks] = CreateShadowsocksProfile,
+ [EConfigType.SOCKS] = CreateSocksProfile,
+ [EConfigType.VLESS] = CreateVlessProfile,
+ [EConfigType.Trojan] = CreateTrojanProfile,
+ [EConfigType.Hysteria2] = CreateHysteria2Profile,
+ [EConfigType.TUIC] = CreateTuicProfile,
+ [EConfigType.WireGuard] = CreateWireguardProfile,
+ [EConfigType.Anytls] = CreateAnytlsProfile,
+ [EConfigType.Naive] = () => CreateNaiveProfile(false),
+ };
+
+ [Test]
+ public async Task ShareUriSuite_ShouldCoverAndRoundTripEveryExportableProtocol()
+ {
+ var uncovered = string.Join(", ", Global.ProtocolShares.Keys.Except(ShareProfileFactories.Keys));
+ var unexpected = string.Join(", ", ShareProfileFactories.Keys.Except(Global.ProtocolShares.Keys));
+
+ await uncovered.Should().BeEqualTo(string.Empty);
+ await unexpected.Should().BeEqualTo(string.Empty);
+
+ foreach (var (configType, factory) in ShareProfileFactories)
+ {
+ var source = factory();
+
+ var resolved = await ExportThenImport(source);
+
+ await resolved.ConfigType.Should().BeEqualTo(configType);
+ await resolved.Address.Should().BeEqualTo(source.Address);
+ await resolved.Port.Should().BeEqualTo(source.Port);
+ }
+ }
+
[Test]
public async Task GetShareUriAndResolveConfig_Vmess_ShouldRoundTripBasicFields()
{
@@ -62,6 +102,165 @@ public class FmtHandlerTests
await resolved.Password.Should().BeEqualTo(source.Password);
}
+ [Test]
+ public async Task GetShareUriAndResolveConfig_Trojan_ShouldRoundTripBasicFields()
+ {
+ var source = CreateTrojanProfile();
+
+ var resolved = await ExportThenImport(source);
+
+ await AssertCommonShareFields(source, resolved);
+ await AssertRawTransportFields(source, resolved);
+ await resolved.Password.Should().BeEqualTo(source.Password);
+ await resolved.Sni.Should().BeEqualTo(source.Sni);
+ await resolved.GetProtocolExtra().Flow.Should().BeEqualTo(source.GetProtocolExtra().Flow);
+ await resolved.GetAllowInsecure().Should().BeTrue();
+
+ // Trojan is the one exporter that writes both spellings of the flag.
+ await AssertExportContains(source, "allowInsecure=1", "insecure=1");
+ }
+
+ [Test]
+ public async Task GetShareUriAndResolveConfig_Tuic_ShouldRoundTripUserInfoAndCongestionControl()
+ {
+ var source = CreateTuicProfile();
+
+ var resolved = await ExportThenImport(source);
+
+ await AssertCommonShareFields(source, resolved);
+ await resolved.Username.Should().BeEqualTo(source.Username);
+ await resolved.Password.Should().BeEqualTo(source.Password);
+ await resolved.Sni.Should().BeEqualTo(source.Sni);
+ await resolved.Alpn.Should().BeEqualTo(source.Alpn);
+ await resolved.GetProtocolExtra().CongestionControl.Should()
+ .BeEqualTo(source.GetProtocolExtra().CongestionControl);
+ await resolved.GetAllowInsecure().Should().BeTrue();
+
+ await AssertExportContains(source, "allow_insecure=1");
+ }
+
+ [Test]
+ public async Task GetShareUriAndResolveConfig_Anytls_ShouldRoundTripBasicFields()
+ {
+ var source = CreateAnytlsProfile();
+
+ var resolved = await ExportThenImport(source);
+
+ await AssertCommonShareFields(source, resolved);
+ await AssertRawTransportFields(source, resolved);
+ await resolved.Password.Should().BeEqualTo(source.Password);
+ await resolved.Sni.Should().BeEqualTo(source.Sni);
+ await resolved.Alpn.Should().BeEqualTo(source.Alpn);
+ await resolved.GetAllowInsecure().Should().BeTrue();
+
+ await AssertExportContains(source, "insecure=1");
+ }
+
+ [Test]
+ public async Task GetShareUriAndResolveConfig_Hysteria2_ShouldRoundTripObfsAndNormalizePortRange()
+ {
+ var source = CreateHysteria2Profile();
+
+ var resolved = await ExportThenImport(source);
+ var sourceExtra = source.GetProtocolExtra();
+ var resolvedExtra = resolved.GetProtocolExtra();
+
+ await AssertCommonShareFields(source, resolved);
+ await resolved.Password.Should().BeEqualTo(source.Password);
+ await resolved.Sni.Should().BeEqualTo(source.Sni);
+ await resolved.Alpn.Should().BeEqualTo(source.Alpn);
+ await resolved.EchConfigList.Should().BeEqualTo(source.EchConfigList);
+ await resolved.GetAllowInsecure().Should().BeTrue();
+ await resolvedExtra.SalamanderPass.Should().BeEqualTo(sourceExtra.SalamanderPass);
+
+ // Hysteria2Fmt stores a port range internally as "5000:6000" and emits the URI form.
+ await resolvedExtra.Ports.Should().BeEqualTo("5000-6000");
+
+ await AssertExportContains(source, "insecure=1", "obfs=salamander", "mport=5000-6000");
+ }
+
+ [Test]
+ public async Task GetShareUriAndResolveConfig_Wireguard_ShouldRoundTripKeysAndInterface()
+ {
+ var source = CreateWireguardProfile();
+
+ var resolved = await ExportThenImport(source);
+ var extra = resolved.GetProtocolExtra();
+ var sourceExtra = source.GetProtocolExtra();
+
+ await AssertCommonShareFields(source, resolved);
+ await resolved.Password.Should().BeEqualTo(source.Password);
+ await extra.WgPublicKey.Should().BeEqualTo(sourceExtra.WgPublicKey);
+ await extra.WgPresharedKey.Should().BeEqualTo(sourceExtra.WgPresharedKey);
+ await extra.WgReserved.Should().BeEqualTo(sourceExtra.WgReserved);
+ await extra.WgInterfaceAddress.Should().BeEqualTo(sourceExtra.WgInterfaceAddress);
+ await extra.WgMtu.Should().BeEqualTo(sourceExtra.WgMtu);
+ }
+
+ [Test]
+ public async Task GetShareUri_Wireguard_ShouldEncodeKeysAndBracketIpv6()
+ {
+ var source = CreateWireguardProfile();
+
+ // Base64 keys carry '/', '+' and '=', and the address is an IPv6 literal: both have to
+ // survive the wire form, which a round trip through the same encoder would not prove.
+ await AssertExportContains(
+ source,
+ Uri.EscapeDataString(source.Password),
+ Uri.EscapeDataString(source.GetProtocolExtra().WgPublicKey ?? string.Empty),
+ "@[2001:db8::40]:51820");
+ }
+
+ [Test]
+ public async Task GetShareUriAndResolveConfig_Naive_ShouldRoundTripCredentialsOverHttps()
+ {
+ var source = CreateNaiveProfile(false);
+
+ var resolved = await ExportThenImport(source, Global.NaiveHttpsProtocolShare);
+
+ await AssertCommonShareFields(source, resolved);
+ await AssertRawTransportFields(source, resolved);
+ await resolved.Username.Should().BeEqualTo(source.Username);
+ await resolved.Password.Should().BeEqualTo(source.Password);
+ await resolved.GetProtocolExtra().InsecureConcurrency.Should()
+ .BeEqualTo(source.GetProtocolExtra().InsecureConcurrency);
+
+ // NaiveFmt only ever sets this flag on the quic branch, so the https branch leaves it
+ // unset rather than false - assert "is not quic" instead of an explicit false.
+ await (resolved.GetProtocolExtra().NaiveQuic == true).Should().BeFalse();
+ }
+
+ [Test]
+ public async Task GetShareUriAndResolveConfig_NaiveQuic_ShouldRoundTripQuicScheme()
+ {
+ var source = CreateNaiveProfile(true);
+
+ var resolved = await ExportThenImport(source, Global.NaiveQuicProtocolShare);
+
+ await AssertCommonShareFields(source, resolved);
+ await AssertRawTransportFields(source, resolved);
+ await resolved.Username.Should().BeEqualTo(source.Username);
+ await resolved.Password.Should().BeEqualTo(source.Password);
+ await resolved.GetProtocolExtra().InsecureConcurrency.Should()
+ .BeEqualTo(source.GetProtocolExtra().InsecureConcurrency);
+ await resolved.GetProtocolExtra().NaiveQuic.Should().BeTrue();
+ }
+
+ [Test]
+ [Arguments("p:a@ss#% +/=")]
+ [Arguments("пароль 東京")]
+ public async Task GetShareUriAndResolveConfig_Trojan_ShouldRoundTripEncodedCredentials(string password)
+ {
+ var source = CreateTrojanProfile();
+ source.Password = password;
+ source.Remarks = "Trojan — тест 東京 #1";
+
+ var resolved = await ExportThenImport(source);
+
+ await resolved.Password.Should().BeEqualTo(password);
+ await resolved.Remarks.Should().BeEqualTo(source.Remarks);
+ }
+
[Test]
public async Task ResolveConfig_UnsupportedProtocol_ShouldReturnNull()
{
@@ -82,14 +281,70 @@ public class FmtHandlerTests
await uri.Should().BeNull();
}
+ private static async Task AssertCommonShareFields(ProfileItem source, ProfileItem resolved)
+ {
+ await resolved.ConfigType.Should().BeEqualTo(source.ConfigType);
+ await resolved.Remarks.Should().BeEqualTo(source.Remarks);
+ await resolved.Address.Should().BeEqualTo(source.Address);
+ await resolved.Port.Should().BeEqualTo(source.Port);
+ }
+
+ ///
+ /// Only for protocols whose exporter goes through the shared transport query
+ /// (security, type, headerType). TUIC, Hysteria2 and WireGuard do not.
+ ///
+ private static async Task AssertRawTransportFields(ProfileItem source, ProfileItem resolved)
+ {
+ await resolved.Network.Should().BeEqualTo(source.Network);
+ await resolved.StreamSecurity.Should().BeEqualTo(source.StreamSecurity);
+ await resolved.GetTransportExtra().RawHeaderType.Should()
+ .BeEqualTo(source.GetTransportExtra().RawHeaderType);
+ }
+
+ ///
+ /// Asserts on the wire form itself. A round trip cannot catch an exporter and an importer that
+ /// agree on the wrong spelling of a parameter, and the insecure flag is spelled differently by
+ /// every protocol.
+ ///
+ private static async Task AssertExportContains(ProfileItem source, params string[] expectedFragments)
+ {
+ var uri = FmtHandler.GetShareUri(source);
+
+ await uri.Should().NotBeNull();
+
+ foreach (var fragment in expectedFragments)
+ {
+ await uri!.Contains(fragment, StringComparison.Ordinal).Should()
+ .BeTrue().Because($"uri: {uri}, expected fragment: {fragment}");
+ }
+ }
+
+ private static string ExpectedShareScheme(ProfileItem item)
+ {
+ if (item.ConfigType != EConfigType.Naive)
+ {
+ return Global.ProtocolShares[item.ConfigType];
+ }
+
+ // NaiveFmt never emits the "naive://" prefix that Global.ProtocolShares records for the
+ // type; that entry is only read when importing.
+ return item.GetProtocolExtra().NaiveQuic == true
+ ? Global.NaiveQuicProtocolShare
+ : Global.NaiveHttpsProtocolShare;
+ }
+
private static async Task ExportThenImport(ProfileItem source)
+ {
+ return await ExportThenImport(source, ExpectedShareScheme(source));
+ }
+
+ private static async Task ExportThenImport(ProfileItem source, string expectedPrefix)
{
var uri = FmtHandler.GetShareUri(source);
await uri.Should().NotBeNull();
await uri.Should().NotBeEmpty();
- await uri!.StartsWith(Global.ProtocolShares[source.ConfigType], StringComparison.OrdinalIgnoreCase).Should()
- .BeTrue();
+ await uri!.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase).Should().BeTrue();
var resolved = FmtHandler.ResolveConfig(uri, out var msg);
@@ -166,4 +421,142 @@ public class FmtHandlerTests
Password = "pass",
};
}
+
+ private static ProfileItem CreateTrojanProfile()
+ {
+ var item = new ProfileItem
+ {
+ ConfigType = EConfigType.Trojan,
+ Remarks = "trojan demo",
+ Address = "trojan.example",
+ Port = 443,
+ Password = "trojan-pass",
+ Network = nameof(ETransport.raw),
+ StreamSecurity = Global.StreamSecurity,
+ Sni = "sni.trojan.example",
+ AllowInsecure = Global.StringTrue,
+ };
+
+ item.SetProtocolExtra(new ProtocolExtraItem { Flow = Global.Flows[1], });
+ item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
+
+ return item;
+ }
+
+ private static ProfileItem CreateTuicProfile()
+ {
+ var item = new ProfileItem
+ {
+ ConfigType = EConfigType.TUIC,
+ Remarks = "tuic demo",
+ Address = "tuic.example",
+ Port = 8443,
+ // A colon separates the two halves of the TUIC user info, so it cannot appear in the
+ // uuid; a fixed value also keeps a failure reproducible.
+ Username = "01234567-89ab-cdef-0123-456789abcdef",
+ Password = "tuic-pass",
+ Sni = "sni.tuic.example",
+ Alpn = "h3",
+ AllowInsecure = Global.StringTrue,
+ };
+
+ item.SetProtocolExtra(new ProtocolExtraItem { CongestionControl = "bbr", });
+
+ return item;
+ }
+
+ private static ProfileItem CreateAnytlsProfile()
+ {
+ var item = new ProfileItem
+ {
+ ConfigType = EConfigType.Anytls,
+ Remarks = "anytls demo",
+ Address = "anytls.example",
+ Port = 8443,
+ Password = "anytls-pass",
+ Network = nameof(ETransport.raw),
+ StreamSecurity = Global.StreamSecurity,
+ Sni = "sni.anytls.example",
+ Alpn = "h2,http/1.1",
+ AllowInsecure = Global.StringTrue,
+ };
+
+ item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
+
+ return item;
+ }
+
+ private static ProfileItem CreateHysteria2Profile()
+ {
+ // CertSha is deliberately left unset: the importer turns AllowInsecure on by itself when a
+ // pinSHA256 is present, which would mask an exporter that stopped emitting insecure=1.
+ var item = new ProfileItem
+ {
+ ConfigType = EConfigType.Hysteria2,
+ Remarks = "hysteria2 demo",
+ Address = "hy2.example",
+ Port = 8443,
+ Password = "demo-user:demo-pass",
+ Sni = "sni.hy2.example",
+ Alpn = "h3",
+ EchConfigList = "AAj+DQAEAAAAAA==",
+ AllowInsecure = Global.StringTrue,
+ };
+
+ item.SetProtocolExtra(new ProtocolExtraItem
+ {
+ SalamanderPass = "salamander-pass",
+ Ports = "5000:6000",
+ });
+
+ return item;
+ }
+
+ private static string CreateWireguardKey(byte value)
+ {
+ return Convert.ToBase64String(Enumerable.Repeat(value, 32).ToArray());
+ }
+
+ private static ProfileItem CreateWireguardProfile()
+ {
+ var item = new ProfileItem
+ {
+ ConfigType = EConfigType.WireGuard,
+ Remarks = "WireGuard — тест 東京 #1",
+ Address = "2001:db8::40",
+ Port = 51820,
+ Password = CreateWireguardKey(0xFE),
+ };
+
+ item.SetProtocolExtra(new ProtocolExtraItem
+ {
+ WgPublicKey = CreateWireguardKey(0xFD),
+ WgPresharedKey = CreateWireguardKey(0xFC),
+ WgReserved = "1,2,255",
+ WgInterfaceAddress = "10.0.0.2/32,fd00::2/128",
+ WgMtu = 1420,
+ });
+
+ return item;
+ }
+
+ private static ProfileItem CreateNaiveProfile(bool quic)
+ {
+ var item = new ProfileItem
+ {
+ ConfigType = EConfigType.Naive,
+ Remarks = quic ? "naive quic demo" : "naive https demo",
+ Address = "naive.example",
+ Port = 443,
+ Username = "naive-user",
+ Password = "päss:word@/?#&=+ 東京",
+ Network = nameof(ETransport.raw),
+ StreamSecurity = Global.None,
+ };
+
+ item.SetProtocolExtra(new ProtocolExtraItem { NaiveQuic = quic, InsecureConcurrency = 4, });
+ item.SetTransportExtra(new TransportExtraItem { RawHeaderType = Global.None, });
+
+ return item;
+ }
}