From e4ab15983a2c1b932e0a5353b4fc58b1871eca23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A7=81=E6=99=93=E7=A9=BA?= Date: Fri, 28 Aug 2026 16:50:51 +0800 Subject: [PATCH 1/8] fix(export): preserve Hysteria2 URI semantics Share one parser between node validation and sing-box export, support default and port-hopping authorities, preserve userpass and IPv6, map Salamander and Gecko, and fail closed for unsupported certificate pinning and ECH. Add focused fixtures, parser/export regressions, warning-first configuration coverage, and update the export matrix and roadmap. No production deployment, Cloudflare secret mutation, or real Gist write is performed. --- docs/ROADMAP.md | 12 +- docs/sing-box-export.md | 49 +++- .../fixtures/hysteria2.invalid.txt | 12 + .../fixtures/hysteria2.valid.txt | 10 + .../client-export/hysteria2-fidelity.test.ts | 236 +++++++++++++++ src/lib/client-export/hysteria2.ts | 65 +++-- src/lib/client-export/uri.ts | 19 +- src/lib/hysteria2-uri.test.ts | 152 ++++++++++ src/lib/hysteria2-uri.ts | 275 ++++++++++++++++++ src/lib/proxy-protocol.ts | 67 ++++- 10 files changed, 852 insertions(+), 45 deletions(-) create mode 100644 src/lib/client-export/fixtures/hysteria2.invalid.txt create mode 100644 src/lib/client-export/fixtures/hysteria2.valid.txt create mode 100644 src/lib/client-export/hysteria2-fidelity.test.ts create mode 100644 src/lib/hysteria2-uri.test.ts create mode 100644 src/lib/hysteria2-uri.ts diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 6c236c7..257fcff 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -13,6 +13,10 @@ not a promise to deploy or mutate a production Workspace. Hysteria2, TUIC, and AnyTLS. ShadowsocksR remains importable and is skipped from sing-box output with a warning; unknown and malformed lines are warning-first. +- Shared Hysteria2 URI validation and export parsing for implicit port `443`, + password and userpass authentication, bracketed IPv6, port-hopping lists and + ranges, and Salamander or Gecko obfuscation. Hysteria2 certificate pinning, + ECH, and Realm sharing fail closed instead of being silently mis-mapped. - Bounded subscription fetching with a 15 second timeout, a 4 MiB response limit, fatal UTF-8 decoding, HTTP status classes, and network/CORS-safe diagnostics. @@ -32,9 +36,11 @@ not a promise to deploy or mutate a production Workspace. ## Next product and reliability work -1. Expand sing-box protocol fidelity from the current URI matrix using checked-in - fixtures and upstream schema review. Each new mapping must preserve the - warning-first behavior for partial or legacy input. +1. Continue sing-box protocol fidelity using checked-in fixtures and upstream + schema review. Candidate slices include verified Hysteria2 ECH semantics, + Realm as a separate sharing scheme, and additional transport/TLS fields for + other supported protocols. Each mapping must preserve warning-first behavior + for partial or legacy input and fail closed for security-sensitive fields. 2. Add the first public API expansion only after the contract in [`docs/api/roadmap.md`](api/roadmap.md) is reviewed. All writes continue to use the Workspace coordinator. diff --git a/docs/sing-box-export.md b/docs/sing-box-export.md index 5023f96..389473d 100644 --- a/docs/sing-box-export.md +++ b/docs/sing-box-export.md @@ -7,8 +7,9 @@ editing and presentation; Workspace publication goes through the existing revisioned mutation boundary. The URI mappings in this document were checked against the current sing-box -outbound documentation for the v1.14 line on 2026-08-28. A mapping is added -only when the source URI field has a reliable sing-box equivalent. +outbound documentation for the v1.14 line and the Hysteria2 URI specification +on 2026-08-28. A mapping is added only when the source URI field has a reliable +sing-box equivalent. ## Supported outbound matrix @@ -18,7 +19,7 @@ only when the source URI field has a reliable sing-box equivalent. | `vmess://` | `vmess` | base64 JSON server/port/UUID, security, alter ID, global padding, authenticated length, network, TLS, transport, packet encoding, multiplex | Includes the legacy `aes-128-ctr` security value; payload must be valid base64 UTF-8 JSON | | `trojan://` | `trojan` | server, port, password, TLS, transport, multiplex | Trojan's TLS outbound is enabled by default | | `ss://` | `shadowsocks` | method, password, server/port, SIP003 plugin/options, UDP-over-TCP, multiplex | Direct and base64 authority forms are accepted | -| `hysteria2://`, `hy2://` | `hysteria2` | server/port, password, network, TLS/SNI/ALPN/insecure, Salamander obfuscation | Only the `salamander` obfuscation type is emitted | +| `hysteria2://`, `hy2://` | `hysteria2` | server, default/single port, port-hopping lists and ranges, password or userpass, network, TLS/SNI/ALPN/insecure, Salamander or Gecko obfuscation | Omitted port defaults to `443`; bracketed IPv6 is accepted; certificate pinning and ECH fail closed until exact mappings exist | | `tuic://` | `tuic` | server/port, UUID, password, congestion control, UDP relay mode, UDP-over-stream, zero-RTT, heartbeat, network, TLS/SNI/ALPN/insecure | Supported congestion controls are `cubic`, `new_reno`, and `bbr` | | `anytls://` | `anytls` | server/port, password, TLS/SNI/ALPN/insecure, idle-session settings | `client_metadata` is never synthesized or copied | | `ssr://` | none | none | The line is skipped with a stable warning; no fake outbound is generated | @@ -36,6 +37,47 @@ The parser also rejects missing required credentials for the protocols that need them. Duplicate display tags are made deterministic by adding a numeric suffix; fixed selector, URL-test, direct, and block tags are reserved. +## Hysteria2 URI fidelity + +SubMan uses one shared Hysteria2 parser for node validation and sing-box export. +This avoids accepting an URI in the editor and rejecting the same URI only when +building a client configuration. + +Supported authority forms include: + +- `hy2://password@example.com`, with an implicit port of `443`; +- `hy2://username:password@example.com:8443`, preserving the complete + `username:password` authentication value; +- independent port lists such as `443,8443,9443`; +- ranges such as `5000-6000`; +- mixed lists and ranges such as `443,5000-6000,8443`; +- bracketed IPv6 hosts with the same port-hopping syntax. + +A single explicit or default port is emitted as `server_port`. Any URI that +contains more than one port or a range is emitted as `server_ports`, without a +conflicting `server_port`. Hysteria2 URI ranges use `start-end`; sing-box parses +port ranges as `start:end`, so SubMan converts each segment deterministically. +Independent ports in a multi-port URI become exact ranges such as `443:443`. + +Both Salamander and Gecko obfuscation are supported when `obfs-password` (or the +legacy `obfs_password` alias) is non-empty. Gecko packet-size values are not +invented because the standard sharing URI does not carry them. + +Known connection-security fields fail closed instead of being silently dropped: + +- Hysteria2 `pinSHA256` is a full certificate fingerprint. It is not equivalent + to sing-box `certificate_public_key_sha256`, which hashes the certificate + public key, so SubMan does not copy or reinterpret it. +- Hysteria2 `ech` is not converted until the source ECH config-list semantics + have a verified mapping to sing-box's structured TLS ECH options. +- `hysteria2+realm://` and `hysteria2+realm+http://` are separate Realm sharing + schemes and are not handled by the ordinary Hysteria2 parser. + +An unsupported or malformed Hysteria2 line produces a stable warning and is +skipped. Other valid lines in the same Aggregate remain eligible for export. +Warnings never include the original URI, authentication value, certificate +fingerprint, ECH payload, or obfuscation password. + ## Subscription input Subscription URLs are fetched by the browser when an Aggregate or export is @@ -74,5 +116,6 @@ Official schema references: - [Trojan outbound](https://sing-box.sagernet.org/configuration/outbound/trojan/) - [Shadowsocks outbound](https://sing-box.sagernet.org/configuration/outbound/shadowsocks/) - [Hysteria2 outbound](https://sing-box.sagernet.org/configuration/outbound/hysteria2/) +- [Hysteria2 URI scheme](https://v2.hysteria.network/docs/developers/URI-Scheme/) - [TUIC outbound](https://sing-box.sagernet.org/configuration/outbound/tuic/) - [AnyTLS outbound](https://sing-box.sagernet.org/configuration/outbound/anytls/) diff --git a/src/lib/client-export/fixtures/hysteria2.invalid.txt b/src/lib/client-export/fixtures/hysteria2.invalid.txt new file mode 100644 index 0000000..a18bb05 --- /dev/null +++ b/src/lib/client-export/fixtures/hysteria2.invalid.txt @@ -0,0 +1,12 @@ +hy2://password@example.com:0#Zero +hy2://password@example.com:65536#Overflow +hy2://password@example.com:6000-5000#Reverse +hy2://password@example.com:-6000#MissingStart +hy2://password@example.com:5000-#MissingEnd +hy2://password@example.com:443,,8443#EmptySegment +hy2://password@example.com:443,#TrailingSeparator +hy2://bad%ZZ@example.com:443#BadPercent +hy2://password@example.com:443?obfs=salamander#MissingObfsPassword +hy2://password@example.com:443?obfs=unknown&obfs-password=secret#UnknownObfs +hy2://blocked-secret@example.com:443?pinSHA256=deadbeef#Pinned +hy2://blocked-secret@example.com:443?ech=ZWNoLWNvbmZpZw==#ECH diff --git a/src/lib/client-export/fixtures/hysteria2.valid.txt b/src/lib/client-export/fixtures/hysteria2.valid.txt new file mode 100644 index 0000000..64002ed --- /dev/null +++ b/src/lib/client-export/fixtures/hysteria2.valid.txt @@ -0,0 +1,10 @@ +hy2://password@example.com#Default +hysteria2://username:password@example.com:8443#Userpass +hy2://password@example.com:443,8443,9443#Ports +hy2://password@example.com:5000-6000#Range +hy2://password@example.com:443,5000-6000,8443#Mixed +hy2://password@[2001:db8::1]:443,5000-6000#IPv6 +hy2://user%3Aname:pass%40word@example.com:443#Encoded +hy2://password@example.com:443?obfs=salamander&obfs-password=obfs-pass#Salamander +hy2://password@example.com:443?obfs=gecko&obfs-password=gecko-pass#Gecko +hy2://password@example.com:443?sni=edge.example.com&alpn=h3&insecure=1#TLS diff --git a/src/lib/client-export/hysteria2-fidelity.test.ts b/src/lib/client-export/hysteria2-fidelity.test.ts new file mode 100644 index 0000000..da3f1aa --- /dev/null +++ b/src/lib/client-export/hysteria2-fidelity.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from "bun:test"; +import type { AggregateRule, NodeItem } from "$lib/models"; +import { validateProxyUri } from "$lib/proxy-protocol"; +import { createDefaultSingBoxClientProfile } from "./profile"; +import { buildSingBoxClientConfig } from "./sing-box"; +import { parseProxyUriToSingBoxOutbound } from "./uri"; + +declare const Bun: { + file(path: URL): { text(): Promise }; +}; + +async function fixtureLines(name: string): Promise { + return (await Bun.file(new URL(`./fixtures/${name}`, import.meta.url)).text()) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +describe("Hysteria2 sing-box URI fidelity", () => { + it("keeps shared validation and export parsing aligned for fixtures", async () => { + for (const raw of await fixtureLines("hysteria2.valid.txt")) { + const validation = validateProxyUri(raw, "hysteria2"); + const parsed = parseProxyUriToSingBoxOutbound(raw, "fixture"); + expect(validation.syntaxValid).toBe(true); + expect(validation.coreFieldsValid).toBe(true); + expect(parsed.warning).toBeNull(); + expect(parsed.outbound).not.toBeNull(); + } + + for (const raw of await fixtureLines("hysteria2.invalid.txt")) { + const validation = validateProxyUri(raw, "hysteria2"); + const parsed = parseProxyUriToSingBoxOutbound(raw, "fixture"); + expect(validation.coreFieldsValid).toBe(false); + expect(parsed.outbound).toBeNull(); + expect(parsed.warning).not.toBeNull(); + } + }); + + it("uses server_port only for a single or default port", () => { + const defaultPort = parseProxyUriToSingBoxOutbound( + "hy2://password@example.com#Default", + "fallback", + ); + const singlePort = parseProxyUriToSingBoxOutbound( + "hy2://password@example.com:8443#Single", + "fallback", + ); + + expect(defaultPort.outbound).toEqual({ + type: "hysteria2", + tag: "Default", + server: "example.com", + server_port: 443, + password: "password", + tls: { enabled: true }, + }); + expect(singlePort.outbound).toEqual({ + type: "hysteria2", + tag: "Single", + server: "example.com", + server_port: 8443, + password: "password", + tls: { enabled: true }, + }); + expect("server_ports" in (defaultPort.outbound ?? {})).toBe(false); + expect("server_ports" in (singlePort.outbound ?? {})).toBe(false); + }); + + it("uses server_ports for lists and ranges without a conflicting port", () => { + const result = parseProxyUriToSingBoxOutbound( + "hy2://password@example.com:443,5000-6000,8443#Mixed", + "fallback", + ); + + expect(result.warning).toBeNull(); + expect(result.outbound).toEqual({ + type: "hysteria2", + tag: "Mixed", + server: "example.com", + server_ports: ["443:443", "5000:6000", "8443:8443"], + password: "password", + tls: { enabled: true }, + }); + expect("server_port" in (result.outbound ?? {})).toBe(false); + }); + + it("preserves userpass and bracketed IPv6", () => { + const result = parseProxyUriToSingBoxOutbound( + "hy2://user%3Aname:pass%40word@[2001:db8::1]:443,5000-6000#IPv6", + "fallback", + ); + + expect(result.warning).toBeNull(); + expect(result.outbound).toEqual({ + type: "hysteria2", + tag: "IPv6", + server: "2001:db8::1", + server_ports: ["443:443", "5000:6000"], + password: "user:name:pass@word", + tls: { enabled: true }, + }); + }); + + it("maps Salamander and Gecko without inventing Gecko packet sizes", () => { + const salamander = parseProxyUriToSingBoxOutbound( + "hy2://password@example.com:443?obfs=salamander&obfs-password=obfs-pass#Salamander", + "fallback", + ); + const gecko = parseProxyUriToSingBoxOutbound( + "hy2://password@example.com:443?obfs=gecko&obfs-password=gecko-pass#Gecko", + "fallback", + ); + + expect(salamander.outbound?.obfs).toEqual({ + type: "salamander", + password: "obfs-pass", + }); + expect(gecko.outbound?.obfs).toEqual({ + type: "gecko", + password: "gecko-pass", + }); + expect(JSON.stringify(gecko.outbound)).not.toContain("min_packet_size"); + expect(JSON.stringify(gecko.outbound)).not.toContain("max_packet_size"); + }); + + it("returns stable safe warnings for pinning and ECH", () => { + const pinned = parseProxyUriToSingBoxOutbound( + "hy2://blocked-password@example.com:443?pinSHA256=deadbeef#Pinned", + "Pinned", + ); + const ech = parseProxyUriToSingBoxOutbound( + "hy2://blocked-password@example.com:443?ech=ZWNoLXNlY3JldA==#ECH", + "ECH", + ); + + expect(pinned.warning).toBe( + "Skipped Hysteria2 URI: certificate pinning is not supported for Pinned", + ); + expect(ech.warning).toBe( + "Skipped Hysteria2 URI: ECH is not supported for ECH", + ); + for (const warning of [pinned.warning ?? "", ech.warning ?? ""]) { + expect(warning).not.toContain("blocked-password"); + expect(warning).not.toContain("deadbeef"); + expect(warning).not.toContain("ZWNoLXNlY3JldA"); + expect(warning).not.toContain("hy2://"); + } + }); +}); + +describe("Hysteria2 warning-first client export", () => { + const updatedAt = "2026-08-28T00:00:00.000Z"; + const rule: AggregateRule = { + id: "rule-hy2", + name: "Hysteria2 fidelity", + nodeIds: ["hy2-valid", "hy2-blocked", "vless-valid"], + subscriptionIds: [], + excludeTagIds: [], + renameMap: {}, + allowedTypes: [], + prependRegionFlags: false, + updatedAt, + }; + const nodes: NodeItem[] = [ + { + id: "hy2-valid", + name: "Valid HY2", + type: "hysteria2", + raw: "hy2://valid-user:valid-pass@example.com:443,5000-5001?obfs=gecko&obfs-password=valid-gecko#Valid%20HY2", + tags: [], + enabled: true, + updatedAt, + source: "single", + }, + { + id: "hy2-blocked", + name: "Blocked HY2", + type: "hysteria2", + raw: "hy2://blocked-hy2-secret@example.net:443?pinSHA256=blocked-fingerprint#Blocked%20HY2", + tags: [], + enabled: true, + updatedAt, + source: "single", + }, + { + id: "vless-valid", + name: "Valid VLESS", + type: "vless", + raw: "vless://00000000-0000-4000-8000-000000000001@example.org:443?security=tls&sni=example.org#Valid%20VLESS", + tags: [], + enabled: true, + updatedAt, + source: "single", + }, + ]; + + it("skips one unsafe Hysteria2 line while retaining other outbounds", async () => { + const profile = createDefaultSingBoxClientProfile("rule-hy2", updatedAt); + const result = await buildSingBoxClientConfig(profile, rule, nodes, []); + + expect(result.errors).toEqual([]); + expect(result.totalLines).toBe(3); + expect(result.outbounds).toBe(2); + expect(result.skipped).toBe(1); + expect(result.warnings).toEqual([ + "Skipped Hysteria2 URI: certificate pinning is not supported for Blocked HY2", + ]); + + const config = result.config as { + outbounds: Array>; + }; + const hysteria2 = config.outbounds.find( + (outbound) => outbound.type === "hysteria2", + ); + expect(hysteria2).toEqual({ + type: "hysteria2", + tag: "Valid HY2", + server: "example.com", + server_ports: ["443:443", "5000:5001"], + password: "valid-user:valid-pass", + tls: { enabled: true }, + obfs: { type: "gecko", password: "valid-gecko" }, + }); + expect( + config.outbounds.some((outbound) => outbound.type === "vless"), + ).toBe(true); + + const warnings = result.warnings.join("\n"); + expect(warnings).not.toContain("blocked-hy2-secret"); + expect(warnings).not.toContain("blocked-fingerprint"); + expect(warnings).not.toContain("hy2://"); + expect(result.content).not.toContain("blocked-hy2-secret"); + expect(result.content).not.toContain("blocked-fingerprint"); + expect(result.content).not.toContain("pinSHA256"); + }); +}); diff --git a/src/lib/client-export/hysteria2.ts b/src/lib/client-export/hysteria2.ts index 6cab2a5..b63e150 100644 --- a/src/lib/client-export/hysteria2.ts +++ b/src/lib/client-export/hysteria2.ts @@ -1,35 +1,31 @@ +import type { + Hysteria2UriIssue, + ParsedHysteria2Uri, +} from "$lib/hysteria2-uri"; import { buildTls, - decodeComponent, - type ParsedProxyUri, type ProtocolParseResult, queryValue, } from "./common"; import type { SingBoxOutbound } from "./uri-types"; export function parseHysteria2( - parsed: ParsedProxyUri, + parsed: ParsedHysteria2Uri, fallbackTag: string, ): ProtocolParseResult { - const username = decodeComponent(parsed.url.username); - const passwordPart = decodeComponent(parsed.url.password); - const password = passwordPart || username; - if (!password) { - return `Invalid Hysteria2 URI: missing password for ${fallbackTag}`; - } - - const obfs = queryValue(parsed.query, "obfs"); - if (obfs && obfs !== "salamander") { - return `Invalid Hysteria2 URI: unsupported obfs for ${fallbackTag}`; - } const outbound: SingBoxOutbound = { type: "hysteria2", - tag: parsed.tag, + tag: parsed.tag ?? fallbackTag, server: parsed.server, - server_port: parsed.serverPort, - password, + password: parsed.password, tls: buildTls(parsed.query), }; + if (parsed.serverPort !== null) { + outbound.server_port = parsed.serverPort; + } else { + outbound.server_ports = parsed.serverPorts; + } + const network = queryValue(parsed.query, "network"); if (network) { if (network !== "tcp" && network !== "udp") { @@ -37,16 +33,35 @@ export function parseHysteria2( } outbound.network = network; } - const obfsPassword = queryValue( - parsed.query, - "obfs-password", - "obfs_password", - ); - if (obfs) { + if (parsed.obfs) { outbound.obfs = { - type: obfs, - ...(obfsPassword ? { password: obfsPassword } : {}), + type: parsed.obfs.type, + password: parsed.obfs.password, }; } return outbound; } + +export function describeHysteria2UriIssue( + issue: Hysteria2UriIssue, + fallbackTag: string, +): string { + switch (issue) { + case "missing-auth": + return `Invalid Hysteria2 URI: missing authentication for ${fallbackTag}`; + case "missing-server": + return `Invalid Hysteria2 URI: missing server for ${fallbackTag}`; + case "invalid-port": + return `Invalid Hysteria2 URI: invalid port specification for ${fallbackTag}`; + case "unsupported-obfs": + return `Invalid Hysteria2 URI: unsupported obfs for ${fallbackTag}`; + case "missing-obfs-password": + return `Invalid Hysteria2 URI: missing obfs password for ${fallbackTag}`; + case "unsupported-pin-sha256": + return `Skipped Hysteria2 URI: certificate pinning is not supported for ${fallbackTag}`; + case "unsupported-ech": + return `Skipped Hysteria2 URI: ECH is not supported for ${fallbackTag}`; + case "malformed-uri": + return `Invalid Hysteria2 URI: ${fallbackTag}`; + } +} diff --git a/src/lib/client-export/uri.ts b/src/lib/client-export/uri.ts index 64db654..a8e62c6 100644 --- a/src/lib/client-export/uri.ts +++ b/src/lib/client-export/uri.ts @@ -1,7 +1,11 @@ import { detectProxyScheme } from "$lib/proxy-protocol"; +import { parseHysteria2Uri } from "$lib/hysteria2-uri"; import { parseAnyTls } from "./anytls"; import { type ProtocolParseResult, parseProxyUrl } from "./common"; -import { parseHysteria2 } from "./hysteria2"; +import { + describeHysteria2UriIssue, + parseHysteria2, +} from "./hysteria2"; import { parseShadowsocks } from "./shadowsocks"; import { parseTrojan } from "./trojan"; import { parseTuic } from "./tuic"; @@ -41,6 +45,15 @@ export function parseProxyUriToSingBoxOutbound( if (protocol === "vmess") { return finish(parseVmess(normalized, fallbackTag)); } + if (protocol === "hysteria2" || protocol === "hy2") { + const parsedHysteria2 = parseHysteria2Uri(normalized); + if (!parsedHysteria2.ok) { + return warning( + describeHysteria2UriIssue(parsedHysteria2.issue, fallbackTag), + ); + } + return finish(parseHysteria2(parsedHysteria2.value, fallbackTag)); + } const parsed = parseProxyUrl(normalized, fallbackTag, protocol); if (typeof parsed === "string") { @@ -61,10 +74,6 @@ export function parseProxyUriToSingBoxOutbound( case "ss": result = parseShadowsocks(normalized, parsed, fallbackTag); break; - case "hysteria2": - case "hy2": - result = parseHysteria2(parsed, fallbackTag); - break; case "tuic": result = parseTuic(parsed, fallbackTag); break; diff --git a/src/lib/hysteria2-uri.test.ts b/src/lib/hysteria2-uri.test.ts new file mode 100644 index 0000000..7e13442 --- /dev/null +++ b/src/lib/hysteria2-uri.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "bun:test"; +import { + parseHysteria2Uri, + type ParsedHysteria2Uri, +} from "./hysteria2-uri"; +import { validateProxyUri } from "./proxy-protocol"; + +function parse(raw: string): ParsedHysteria2Uri { + const result = parseHysteria2Uri(raw); + if (!result.ok) throw new Error(`unexpected Hysteria2 issue: ${result.issue}`); + return result.value; +} + +describe("Hysteria2 shared URI parsing", () => { + it("defaults an omitted port to 443", () => { + const parsed = parse("hy2://password@example.com#Default"); + + expect(parsed.server).toBe("example.com"); + expect(parsed.serverPort).toBe(443); + expect(parsed.serverPorts).toEqual([]); + expect(parsed.password).toBe("password"); + expect(parsed.tag).toBe("Default"); + }); + + it("preserves userpass authentication after component decoding", () => { + const parsed = parse( + "hysteria2://user%3Aname:pass%40word@example.com:8443#Userpass", + ); + + expect(parsed.password).toBe("user:name:pass@word"); + expect(parsed.serverPort).toBe(8443); + }); + + it("converts multi-port and range syntax for sing-box", () => { + const ports = parse( + "hy2://password@example.com:443,8443,9443#Ports", + ); + const range = parse("hy2://password@example.com:5000-6000#Range"); + const mixed = parse( + "hy2://password@example.com:443,5000-6000,8443#Mixed", + ); + + expect(ports.serverPort).toBeNull(); + expect(ports.serverPorts).toEqual([ + "443:443", + "8443:8443", + "9443:9443", + ]); + expect(range.serverPort).toBeNull(); + expect(range.serverPorts).toEqual(["5000:6000"]); + expect(mixed.serverPorts).toEqual([ + "443:443", + "5000:6000", + "8443:8443", + ]); + }); + + it("accepts bracketed IPv6 with port hopping", () => { + const parsed = parse( + "hy2://password@[2001:db8::1]:443,5000-6000#IPv6", + ); + + expect(parsed.server).toBe("2001:db8::1"); + expect(parsed.serverPort).toBeNull(); + expect(parsed.serverPorts).toEqual(["443:443", "5000:6000"]); + }); + + it("rejects malformed port specifications deterministically", () => { + for (const raw of [ + "hy2://password@example.com:0", + "hy2://password@example.com:65536", + "hy2://password@example.com:6000-5000", + "hy2://password@example.com:-6000", + "hy2://password@example.com:5000-", + "hy2://password@example.com:443,,8443", + "hy2://password@example.com:443,", + ]) { + expect(parseHysteria2Uri(raw)).toEqual({ + ok: false, + issue: "invalid-port", + }); + } + }); + + it("fails closed for unsupported security semantics", () => { + expect( + parseHysteria2Uri( + "hy2://password@example.com:443?pinSHA256=deadbeef", + ), + ).toEqual({ ok: false, issue: "unsupported-pin-sha256" }); + expect( + parseHysteria2Uri( + "hy2://password@example.com:443?ech=ZWNoLWNvbmZpZw==", + ), + ).toEqual({ ok: false, issue: "unsupported-ech" }); + }); + + it("requires passwords for supported obfuscation types", () => { + expect( + parseHysteria2Uri( + "hy2://password@example.com:443?obfs=salamander", + ), + ).toEqual({ ok: false, issue: "missing-obfs-password" }); + expect( + parseHysteria2Uri( + "hy2://password@example.com:443?obfs=unknown&obfs-password=secret", + ), + ).toEqual({ ok: false, issue: "unsupported-obfs" }); + }); +}); + +describe("Hysteria2 shared validation", () => { + it("accepts default and multi-port forms without a missing-port issue", () => { + for (const raw of [ + "hy2://password@example.com", + "hysteria2://password@example.com:443,5000-6000", + ]) { + const result = validateProxyUri(raw, "hysteria2"); + expect(result.syntaxValid).toBe(true); + expect(result.coreFieldsValid).toBe(true); + expect(result.issues).not.toContain("missing-port"); + } + }); + + it("keeps malformed ranges core-invalid while preserving scheme inference", () => { + const result = validateProxyUri( + "hy2://password@example.com:6000-5000", + "hysteria2", + ); + + expect(result.inferredType).toBe("hysteria2"); + expect(result.syntaxValid).toBe(true); + expect(result.coreFieldsValid).toBe(false); + expect(result.issues).toContain("invalid-port"); + }); + + it("surfaces unsupported certificate pinning and ECH", () => { + const pinned = validateProxyUri( + "hy2://password@example.com?pinSHA256=deadbeef", + "hysteria2", + ); + const ech = validateProxyUri( + "hy2://password@example.com?ech=ZWNoLWNvbmZpZw==", + "hysteria2", + ); + + expect(pinned.coreFieldsValid).toBe(false); + expect(pinned.issues).toContain("unsupported-pin-sha256"); + expect(ech.coreFieldsValid).toBe(false); + expect(ech.issues).toContain("unsupported-ech"); + }); +}); diff --git a/src/lib/hysteria2-uri.ts b/src/lib/hysteria2-uri.ts new file mode 100644 index 0000000..06ffb44 --- /dev/null +++ b/src/lib/hysteria2-uri.ts @@ -0,0 +1,275 @@ +export type Hysteria2Obfs = { + type: "salamander" | "gecko"; + password: string; +}; + +export type ParsedHysteria2Uri = { + scheme: "hysteria2" | "hy2"; + server: string; + serverPort: number | null; + serverPorts: string[]; + password: string; + tag: string | null; + query: URLSearchParams; + obfs: Hysteria2Obfs | null; +}; + +export type Hysteria2UriIssue = + | "malformed-uri" + | "missing-auth" + | "missing-server" + | "invalid-port" + | "unsupported-obfs" + | "missing-obfs-password" + | "unsupported-pin-sha256" + | "unsupported-ech"; + +export type Hysteria2UriParseResult = + | { ok: true; value: ParsedHysteria2Uri } + | { ok: false; issue: Hysteria2UriIssue }; + +type PortSegment = { + start: number; + end: number; + isRange: boolean; +}; + +const DEFAULT_HYSTERIA2_PORT = 443; +const HYSTERIA2_SCHEME_PATTERN = /^(hysteria2|hy2):\/\//i; +const INVALID_PERCENT_ENCODING = /%(?![0-9a-f]{2})/i; + +export function parseHysteria2Uri(raw: string): Hysteria2UriParseResult { + const normalized = raw.trim(); + const schemeMatch = normalized.match(HYSTERIA2_SCHEME_PATTERN); + if (!schemeMatch || INVALID_PERCENT_ENCODING.test(normalized)) { + return failure("malformed-uri"); + } + + const scheme = schemeMatch[1].toLowerCase() as "hysteria2" | "hy2"; + const remainder = normalized.slice(schemeMatch[0].length); + const fragmentIndex = remainder.indexOf("#"); + const beforeFragment = + fragmentIndex >= 0 ? remainder.slice(0, fragmentIndex) : remainder; + const fragment = fragmentIndex >= 0 ? remainder.slice(fragmentIndex + 1) : ""; + if (fragment.includes("#")) return failure("malformed-uri"); + + const queryIndex = beforeFragment.indexOf("?"); + const beforeQuery = + queryIndex >= 0 ? beforeFragment.slice(0, queryIndex) : beforeFragment; + const queryString = queryIndex >= 0 ? beforeFragment.slice(queryIndex + 1) : ""; + const slashIndex = beforeQuery.indexOf("/"); + const authority = slashIndex >= 0 ? beforeQuery.slice(0, slashIndex) : beforeQuery; + const path = slashIndex >= 0 ? beforeQuery.slice(slashIndex) : ""; + if (path && path !== "/") return failure("malformed-uri"); + + const atIndex = authority.lastIndexOf("@"); + if (atIndex < 0) return failure("missing-auth"); + if (authority.slice(0, atIndex).includes("@")) { + return failure("malformed-uri"); + } + + const rawAuth = authority.slice(0, atIndex); + const serverAuthority = authority.slice(atIndex + 1); + const password = parseAuthentication(rawAuth); + if (!password) return failure("missing-auth"); + + const serverResult = parseServerAuthority(serverAuthority); + if (serverResult.ok === false) { + return failure(serverResult.issue); + } + + const query = new URLSearchParams(queryString); + if (hasNonEmptyQueryValue(query, "pinSHA256")) { + return failure("unsupported-pin-sha256"); + } + if (hasNonEmptyQueryValue(query, "ech")) { + return failure("unsupported-ech"); + } + + const obfsType = firstNonEmptyQueryValue(query, "obfs")?.toLowerCase(); + let obfs: Hysteria2Obfs | null = null; + if (obfsType) { + if (obfsType !== "salamander" && obfsType !== "gecko") { + return failure("unsupported-obfs"); + } + const obfsPassword = firstNonEmptyQueryValue( + query, + "obfs-password", + "obfs_password", + ); + if (!obfsPassword) return failure("missing-obfs-password"); + obfs = { type: obfsType, password: obfsPassword }; + } + + let tag: string | null = null; + if (fragment) { + try { + tag = decodeURIComponent(fragment) || null; + } catch { + return failure("malformed-uri"); + } + } + + return { + ok: true, + value: { + scheme, + server: serverResult.server, + serverPort: serverResult.serverPort, + serverPorts: serverResult.serverPorts, + password, + tag, + query, + obfs, + }, + }; +} + +function parseAuthentication(rawAuth: string): string | null { + if (!rawAuth) return null; + const separatorIndex = rawAuth.indexOf(":"); + try { + if (separatorIndex < 0) { + return decodeURIComponent(rawAuth) || null; + } + if (rawAuth.indexOf(":", separatorIndex + 1) >= 0) return null; + const username = decodeURIComponent(rawAuth.slice(0, separatorIndex)); + const password = decodeURIComponent(rawAuth.slice(separatorIndex + 1)); + if (!username || !password) return null; + return `${username}:${password}`; + } catch { + return null; + } +} + +function parseServerAuthority( + value: string, +): + | { + ok: true; + server: string; + serverPort: number | null; + serverPorts: string[]; + } + | { ok: false; issue: "missing-server" | "invalid-port" | "malformed-uri" } { + if (!value) return failure("missing-server"); + + let hostLiteral = ""; + let portSpec: string | null = null; + if (value.startsWith("[")) { + const closingBracket = value.indexOf("]"); + if (closingBracket < 0) return failure("malformed-uri"); + hostLiteral = value.slice(0, closingBracket + 1); + const suffix = value.slice(closingBracket + 1); + if (suffix) { + if (!suffix.startsWith(":")) return failure("malformed-uri"); + portSpec = suffix.slice(1); + } + } else { + const colonIndex = value.lastIndexOf(":"); + if (colonIndex >= 0) { + hostLiteral = value.slice(0, colonIndex); + portSpec = value.slice(colonIndex + 1); + if (hostLiteral.includes(":")) return failure("malformed-uri"); + } else { + hostLiteral = value; + } + } + + const server = normalizeServer(hostLiteral); + if (!server) return failure("missing-server"); + if (portSpec === "") return failure("invalid-port"); + + const ports = parsePortSpec(portSpec); + if (!ports) return failure("invalid-port"); + const usesServerPorts = + ports.length > 1 || ports.some((segment) => segment.isRange); + if (!usesServerPorts) { + return { + ok: true, + server, + serverPort: ports[0].start, + serverPorts: [], + }; + } + + return { + ok: true, + server, + serverPort: null, + serverPorts: ports.map((segment) => `${segment.start}:${segment.end}`), + }; +} + +function normalizeServer(hostLiteral: string): string | null { + if (!hostLiteral) return null; + try { + const probe = new URL(`http://${hostLiteral}:443`); + if (!probe.hostname || probe.username || probe.password) return null; + const hostname = probe.hostname; + return hostname.startsWith("[") && hostname.endsWith("]") + ? hostname.slice(1, -1) + : hostname; + } catch { + return null; + } +} + +function parsePortSpec(value: string | null): PortSegment[] | null { + if (value === null) { + return [ + { + start: DEFAULT_HYSTERIA2_PORT, + end: DEFAULT_HYSTERIA2_PORT, + isRange: false, + }, + ]; + } + + const rawSegments = value.split(","); + if (rawSegments.length === 0 || rawSegments.some((segment) => !segment)) { + return null; + } + + const segments: PortSegment[] = []; + for (const rawSegment of rawSegments) { + const match = rawSegment.match(/^(\d+)(?:-(\d+))?$/); + if (!match) return null; + const start = Number(match[1]); + const end = match[2] ? Number(match[2]) : start; + if (!isValidPort(start) || !isValidPort(end) || start > end) { + return null; + } + segments.push({ start, end, isRange: Boolean(match[2]) }); + } + return segments; +} + +function isValidPort(value: number): boolean { + return Number.isSafeInteger(value) && value >= 1 && value <= 65_535; +} + +function firstNonEmptyQueryValue( + query: URLSearchParams, + ...names: string[] +): string | null { + for (const name of names) { + for (const value of query.getAll(name)) { + if (value.trim()) return value.trim(); + } + } + return null; +} + +function hasNonEmptyQueryValue( + query: URLSearchParams, + name: string, +): boolean { + return query.getAll(name).some((value) => Boolean(value.trim())); +} + +function failure( + issue: T, +): { ok: false; issue: T } { + return { ok: false, issue }; +} diff --git a/src/lib/proxy-protocol.ts b/src/lib/proxy-protocol.ts index ba52ca1..7ab7c7a 100644 --- a/src/lib/proxy-protocol.ts +++ b/src/lib/proxy-protocol.ts @@ -1,3 +1,7 @@ +import { + parseHysteria2Uri, + type Hysteria2UriIssue, +} from "$lib/hysteria2-uri"; import type { ProxyType } from "$lib/models"; export const PROXY_TYPES = [ @@ -70,6 +74,38 @@ export function validateProxyUri( }; } + if ( + declaredType && + declaredType !== "other" && + inferredType !== "other" && + declaredType !== inferredType + ) { + issues.push("declared-type-mismatch"); + } + + if (inferredType === "hysteria2") { + const parsed = parseHysteria2Uri(raw); + if (!parsed.ok) { + issues.push(hysteria2ValidationIssue(parsed.issue)); + return { + scheme, + inferredType, + declaredType: declaredType ?? null, + syntaxValid: parsed.issue !== "malformed-uri", + coreFieldsValid: false, + issues, + }; + } + return { + scheme, + inferredType, + declaredType: declaredType ?? null, + syntaxValid: true, + coreFieldsValid: true, + issues, + }; + } + let url: URL; try { url = new URL(raw.trim()); @@ -85,14 +121,6 @@ export function validateProxyUri( } if (inferredType === "other") issues.push("unsupported-scheme"); - if ( - declaredType && - declaredType !== "other" && - inferredType !== "other" && - declaredType !== inferredType - ) { - issues.push("declared-type-mismatch"); - } const decoded = decodeBase64Json(raw, scheme); const server = decoded?.server ?? url.hostname; @@ -106,7 +134,7 @@ export function validateProxyUri( if (!isUuid(decoded?.credential ?? url.username)) issues.push("missing-uuid"); } - if (inferredType === "trojan" || inferredType === "hysteria2") { + if (inferredType === "trojan") { if (!(url.username || url.password)) issues.push("missing-password"); } if (inferredType === "tuic") { @@ -137,6 +165,27 @@ export function validateProxyUri( }; } +function hysteria2ValidationIssue(issue: Hysteria2UriIssue): string { + switch (issue) { + case "missing-auth": + return "missing-password"; + case "missing-server": + return "missing-server"; + case "invalid-port": + return "invalid-port"; + case "unsupported-obfs": + return "unsupported-obfs"; + case "missing-obfs-password": + return "missing-obfs-password"; + case "unsupported-pin-sha256": + return "unsupported-pin-sha256"; + case "unsupported-ech": + return "unsupported-ech"; + case "malformed-uri": + return "malformed-uri"; + } +} + function isUuid(value: string | null | undefined): boolean { return Boolean( value && From b9bc8a4506e765cc3491332c01bb104f8ec50424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A7=81=E6=99=93=E7=A9=BA?= Date: Fri, 28 Aug 2026 16:55:38 +0800 Subject: [PATCH 2/8] test(export): use typed Hysteria2 assertions Replace unsupported negated null matchers with equivalent boolean assertions so the tests remain compatible with the repository's minimal Bun matcher declarations. --- src/lib/client-export/hysteria2-fidelity.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/client-export/hysteria2-fidelity.test.ts b/src/lib/client-export/hysteria2-fidelity.test.ts index da3f1aa..b686c9f 100644 --- a/src/lib/client-export/hysteria2-fidelity.test.ts +++ b/src/lib/client-export/hysteria2-fidelity.test.ts @@ -24,7 +24,7 @@ describe("Hysteria2 sing-box URI fidelity", () => { expect(validation.syntaxValid).toBe(true); expect(validation.coreFieldsValid).toBe(true); expect(parsed.warning).toBeNull(); - expect(parsed.outbound).not.toBeNull(); + expect(Boolean(parsed.outbound)).toBe(true); } for (const raw of await fixtureLines("hysteria2.invalid.txt")) { @@ -32,7 +32,7 @@ describe("Hysteria2 sing-box URI fidelity", () => { const parsed = parseProxyUriToSingBoxOutbound(raw, "fixture"); expect(validation.coreFieldsValid).toBe(false); expect(parsed.outbound).toBeNull(); - expect(parsed.warning).not.toBeNull(); + expect(Boolean(parsed.warning)).toBe(true); } }); From eed758f664f6390cc27a874e4b812921c87e710c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A7=81=E6=99=93=E7=A9=BA?= Date: Fri, 28 Aug 2026 16:57:44 +0800 Subject: [PATCH 3/8] style(export): format Hysteria2 implementation --- src/lib/client-export/hysteria2.ts | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/lib/client-export/hysteria2.ts b/src/lib/client-export/hysteria2.ts index b63e150..8134987 100644 --- a/src/lib/client-export/hysteria2.ts +++ b/src/lib/client-export/hysteria2.ts @@ -1,12 +1,5 @@ -import type { - Hysteria2UriIssue, - ParsedHysteria2Uri, -} from "$lib/hysteria2-uri"; -import { - buildTls, - type ProtocolParseResult, - queryValue, -} from "./common"; +import type { Hysteria2UriIssue, ParsedHysteria2Uri } from "$lib/hysteria2-uri"; +import { buildTls, type ProtocolParseResult, queryValue } from "./common"; import type { SingBoxOutbound } from "./uri-types"; export function parseHysteria2( From 198fad6d34a0fecfe0ddc1584113f737f308dd2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A7=81=E6=99=93=E7=A9=BA?= Date: Fri, 28 Aug 2026 16:58:10 +0800 Subject: [PATCH 4/8] style(export): organize Hysteria2 URI imports --- src/lib/client-export/uri.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lib/client-export/uri.ts b/src/lib/client-export/uri.ts index a8e62c6..16d1c6b 100644 --- a/src/lib/client-export/uri.ts +++ b/src/lib/client-export/uri.ts @@ -1,11 +1,8 @@ -import { detectProxyScheme } from "$lib/proxy-protocol"; import { parseHysteria2Uri } from "$lib/hysteria2-uri"; +import { detectProxyScheme } from "$lib/proxy-protocol"; import { parseAnyTls } from "./anytls"; import { type ProtocolParseResult, parseProxyUrl } from "./common"; -import { - describeHysteria2UriIssue, - parseHysteria2, -} from "./hysteria2"; +import { describeHysteria2UriIssue, parseHysteria2 } from "./hysteria2"; import { parseShadowsocks } from "./shadowsocks"; import { parseTrojan } from "./trojan"; import { parseTuic } from "./tuic"; From 7bdab2d22f188b826eacb531f3261a0c17501f73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A7=81=E6=99=93=E7=A9=BA?= Date: Fri, 28 Aug 2026 16:58:43 +0800 Subject: [PATCH 5/8] style(export): format Hysteria2 parser tests --- src/lib/hysteria2-uri.test.ts | 44 +++++++++-------------------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/src/lib/hysteria2-uri.test.ts b/src/lib/hysteria2-uri.test.ts index 7e13442..9e690f6 100644 --- a/src/lib/hysteria2-uri.test.ts +++ b/src/lib/hysteria2-uri.test.ts @@ -1,13 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { - parseHysteria2Uri, - type ParsedHysteria2Uri, -} from "./hysteria2-uri"; +import { parseHysteria2Uri, type ParsedHysteria2Uri } from "./hysteria2-uri"; import { validateProxyUri } from "./proxy-protocol"; function parse(raw: string): ParsedHysteria2Uri { const result = parseHysteria2Uri(raw); - if (!result.ok) throw new Error(`unexpected Hysteria2 issue: ${result.issue}`); + if (!result.ok) + throw new Error(`unexpected Hysteria2 issue: ${result.issue}`); return result.value; } @@ -32,33 +30,19 @@ describe("Hysteria2 shared URI parsing", () => { }); it("converts multi-port and range syntax for sing-box", () => { - const ports = parse( - "hy2://password@example.com:443,8443,9443#Ports", - ); + const ports = parse("hy2://password@example.com:443,8443,9443#Ports"); const range = parse("hy2://password@example.com:5000-6000#Range"); - const mixed = parse( - "hy2://password@example.com:443,5000-6000,8443#Mixed", - ); + const mixed = parse("hy2://password@example.com:443,5000-6000,8443#Mixed"); expect(ports.serverPort).toBeNull(); - expect(ports.serverPorts).toEqual([ - "443:443", - "8443:8443", - "9443:9443", - ]); + expect(ports.serverPorts).toEqual(["443:443", "8443:8443", "9443:9443"]); expect(range.serverPort).toBeNull(); expect(range.serverPorts).toEqual(["5000:6000"]); - expect(mixed.serverPorts).toEqual([ - "443:443", - "5000:6000", - "8443:8443", - ]); + expect(mixed.serverPorts).toEqual(["443:443", "5000:6000", "8443:8443"]); }); it("accepts bracketed IPv6 with port hopping", () => { - const parsed = parse( - "hy2://password@[2001:db8::1]:443,5000-6000#IPv6", - ); + const parsed = parse("hy2://password@[2001:db8::1]:443,5000-6000#IPv6"); expect(parsed.server).toBe("2001:db8::1"); expect(parsed.serverPort).toBeNull(); @@ -84,22 +68,16 @@ describe("Hysteria2 shared URI parsing", () => { it("fails closed for unsupported security semantics", () => { expect( - parseHysteria2Uri( - "hy2://password@example.com:443?pinSHA256=deadbeef", - ), + parseHysteria2Uri("hy2://password@example.com:443?pinSHA256=deadbeef"), ).toEqual({ ok: false, issue: "unsupported-pin-sha256" }); expect( - parseHysteria2Uri( - "hy2://password@example.com:443?ech=ZWNoLWNvbmZpZw==", - ), + parseHysteria2Uri("hy2://password@example.com:443?ech=ZWNoLWNvbmZpZw=="), ).toEqual({ ok: false, issue: "unsupported-ech" }); }); it("requires passwords for supported obfuscation types", () => { expect( - parseHysteria2Uri( - "hy2://password@example.com:443?obfs=salamander", - ), + parseHysteria2Uri("hy2://password@example.com:443?obfs=salamander"), ).toEqual({ ok: false, issue: "missing-obfs-password" }); expect( parseHysteria2Uri( From 59705a52c450b6b3d724fa6a914b26da6c464dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A7=81=E6=99=93=E7=A9=BA?= Date: Fri, 28 Aug 2026 16:59:23 +0800 Subject: [PATCH 6/8] chore(ci): stage one-shot Hysteria2 formatting --- .github/workflows/format-hysteria2.yml | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/format-hysteria2.yml diff --git a/.github/workflows/format-hysteria2.yml b/.github/workflows/format-hysteria2.yml new file mode 100644 index 0000000..a01ed86 --- /dev/null +++ b/.github/workflows/format-hysteria2.yml @@ -0,0 +1,59 @@ +name: Apply Hysteria2 formatting + +on: + push: + branches: + - fix/hysteria2-uri-fidelity-20260828 + +permissions: + contents: write + +concurrency: + group: hysteria2-format-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out feature branch + uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Apply Biome formatting + run: | + bunx biome check --write \ + src/lib/client-export/hysteria2-fidelity.test.ts \ + src/lib/client-export/hysteria2.ts \ + src/lib/client-export/uri.ts \ + src/lib/hysteria2-uri.test.ts \ + src/lib/hysteria2-uri.ts \ + src/lib/proxy-protocol.ts + + - name: Commit formatting changes + run: | + git diff --check + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + src/lib/client-export/hysteria2-fidelity.test.ts \ + src/lib/client-export/hysteria2.ts \ + src/lib/client-export/uri.ts \ + src/lib/hysteria2-uri.test.ts \ + src/lib/hysteria2-uri.ts \ + src/lib/proxy-protocol.ts + if ! git diff --cached --quiet; then + git commit -m "style(export): apply Biome formatting" + git push origin "HEAD:${GITHUB_REF_NAME}" + fi From 56c9722f2172a96b0ad9aa495f7e262fc1bac406 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:59:38 +0000 Subject: [PATCH 7/8] style(export): apply Biome formatting --- src/lib/client-export/hysteria2-fidelity.test.ts | 6 +++--- src/lib/hysteria2-uri.test.ts | 2 +- src/lib/hysteria2-uri.ts | 15 ++++++--------- src/lib/proxy-protocol.ts | 5 +---- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/lib/client-export/hysteria2-fidelity.test.ts b/src/lib/client-export/hysteria2-fidelity.test.ts index b686c9f..347bb5b 100644 --- a/src/lib/client-export/hysteria2-fidelity.test.ts +++ b/src/lib/client-export/hysteria2-fidelity.test.ts @@ -221,9 +221,9 @@ describe("Hysteria2 warning-first client export", () => { tls: { enabled: true }, obfs: { type: "gecko", password: "valid-gecko" }, }); - expect( - config.outbounds.some((outbound) => outbound.type === "vless"), - ).toBe(true); + expect(config.outbounds.some((outbound) => outbound.type === "vless")).toBe( + true, + ); const warnings = result.warnings.join("\n"); expect(warnings).not.toContain("blocked-hy2-secret"); diff --git a/src/lib/hysteria2-uri.test.ts b/src/lib/hysteria2-uri.test.ts index 9e690f6..aa39ac1 100644 --- a/src/lib/hysteria2-uri.test.ts +++ b/src/lib/hysteria2-uri.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { parseHysteria2Uri, type ParsedHysteria2Uri } from "./hysteria2-uri"; +import { type ParsedHysteria2Uri, parseHysteria2Uri } from "./hysteria2-uri"; import { validateProxyUri } from "./proxy-protocol"; function parse(raw: string): ParsedHysteria2Uri { diff --git a/src/lib/hysteria2-uri.ts b/src/lib/hysteria2-uri.ts index 06ffb44..dd05d16 100644 --- a/src/lib/hysteria2-uri.ts +++ b/src/lib/hysteria2-uri.ts @@ -56,9 +56,11 @@ export function parseHysteria2Uri(raw: string): Hysteria2UriParseResult { const queryIndex = beforeFragment.indexOf("?"); const beforeQuery = queryIndex >= 0 ? beforeFragment.slice(0, queryIndex) : beforeFragment; - const queryString = queryIndex >= 0 ? beforeFragment.slice(queryIndex + 1) : ""; + const queryString = + queryIndex >= 0 ? beforeFragment.slice(queryIndex + 1) : ""; const slashIndex = beforeQuery.indexOf("/"); - const authority = slashIndex >= 0 ? beforeQuery.slice(0, slashIndex) : beforeQuery; + const authority = + slashIndex >= 0 ? beforeQuery.slice(0, slashIndex) : beforeQuery; const path = slashIndex >= 0 ? beforeQuery.slice(slashIndex) : ""; if (path && path !== "/") return failure("malformed-uri"); @@ -142,9 +144,7 @@ function parseAuthentication(rawAuth: string): string | null { } } -function parseServerAuthority( - value: string, -): +function parseServerAuthority(value: string): | { ok: true; server: string; @@ -261,10 +261,7 @@ function firstNonEmptyQueryValue( return null; } -function hasNonEmptyQueryValue( - query: URLSearchParams, - name: string, -): boolean { +function hasNonEmptyQueryValue(query: URLSearchParams, name: string): boolean { return query.getAll(name).some((value) => Boolean(value.trim())); } diff --git a/src/lib/proxy-protocol.ts b/src/lib/proxy-protocol.ts index 7ab7c7a..606bdd6 100644 --- a/src/lib/proxy-protocol.ts +++ b/src/lib/proxy-protocol.ts @@ -1,7 +1,4 @@ -import { - parseHysteria2Uri, - type Hysteria2UriIssue, -} from "$lib/hysteria2-uri"; +import { type Hysteria2UriIssue, parseHysteria2Uri } from "$lib/hysteria2-uri"; import type { ProxyType } from "$lib/models"; export const PROXY_TYPES = [ From a332f6d21d2a26ecbeb378ff7d9c03f1778e9bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A7=81=E6=99=93=E7=A9=BA?= Date: Fri, 28 Aug 2026 17:00:10 +0800 Subject: [PATCH 8/8] chore(ci): remove one-shot formatting workflow --- .github/workflows/format-hysteria2.yml | 59 -------------------------- 1 file changed, 59 deletions(-) delete mode 100644 .github/workflows/format-hysteria2.yml diff --git a/.github/workflows/format-hysteria2.yml b/.github/workflows/format-hysteria2.yml deleted file mode 100644 index a01ed86..0000000 --- a/.github/workflows/format-hysteria2.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Apply Hysteria2 formatting - -on: - push: - branches: - - fix/hysteria2-uri-fidelity-20260828 - -permissions: - contents: write - -concurrency: - group: hysteria2-format-${{ github.ref }} - cancel-in-progress: true - -jobs: - format: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Check out feature branch - uses: actions/checkout@v6 - with: - ref: ${{ github.ref_name }} - - - name: Set up Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.14 - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Apply Biome formatting - run: | - bunx biome check --write \ - src/lib/client-export/hysteria2-fidelity.test.ts \ - src/lib/client-export/hysteria2.ts \ - src/lib/client-export/uri.ts \ - src/lib/hysteria2-uri.test.ts \ - src/lib/hysteria2-uri.ts \ - src/lib/proxy-protocol.ts - - - name: Commit formatting changes - run: | - git diff --check - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - src/lib/client-export/hysteria2-fidelity.test.ts \ - src/lib/client-export/hysteria2.ts \ - src/lib/client-export/uri.ts \ - src/lib/hysteria2-uri.test.ts \ - src/lib/hysteria2-uri.ts \ - src/lib/proxy-protocol.ts - if ! git diff --cached --quiet; then - git commit -m "style(export): apply Biome formatting" - git push origin "HEAD:${GITHUB_REF_NAME}" - fi