Decode percent-encoded tags and filter release artifacts

This commit is contained in:
2026-07-08 21:14:00 +03:00
parent f6b722b22a
commit 2e80f7b8eb
8 changed files with 179 additions and 9 deletions

View File

@@ -386,6 +386,25 @@ function Invoke-NativeCommand {
}
}
function Clear-ReleaseBundleOutput {
if (-not (Test-Path -LiteralPath $BundleRoot)) {
return
}
$targetRoot = Get-FullPath -Path (Join-Path $RepoRoot "src-tauri\target")
$bundleFull = Get-FullPath -Path $BundleRoot
if (
$bundleFull.Equals($targetRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
-not (Test-IsSubPath -Parent $targetRoot -Child $bundleFull)
) {
throw "Refusing to remove bundle directory outside src-tauri target: $bundleFull"
}
Write-Host ""
Write-Host "Cleaning stale Tauri bundle output: $bundleFull"
Remove-Item -LiteralPath $bundleFull -Recurse -Force
}
function Invoke-ReleaseBuild {
if ($SkipBuild) {
Write-Host ""
@@ -402,22 +421,50 @@ function Invoke-ReleaseBuild {
Write-Host "Skipping Rust tests because -SkipTests was provided."
}
Clear-ReleaseBundleOutput
Invoke-NativeCommand -Name "Tauri release build" -FilePath "npm" -Arguments @("run", "tauri", "--", "build")
}
function Get-ArtifactVersionPattern {
param([string]$TargetVersion)
"(^|[^0-9A-Za-z])$([regex]::Escape($TargetVersion))([^0-9A-Za-z]|$)"
}
function Copy-ReleaseArtifacts {
param([string]$ReleaseDir)
param(
[string]$ReleaseDir,
[string]$TargetVersion
)
if (-not (Test-Path -LiteralPath $BundleRoot)) {
throw "Tauri bundle output was not found: $BundleRoot"
}
$artifactDir = Join-Path $ReleaseDir "artifacts"
$files = Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") }
$allFiles = @(Get-ChildItem -LiteralPath $BundleRoot -Recurse -File |
Where-Object { $_.Extension -in @(".exe", ".msi", ".zip", ".sig") } |
Sort-Object FullName)
if ($allFiles.Count -eq 0) {
throw "No release artifacts were found under $BundleRoot."
}
$versionPattern = Get-ArtifactVersionPattern -TargetVersion $TargetVersion
$files = @($allFiles | Where-Object { $_.Name -match $versionPattern })
$ignoredFiles = @($allFiles | Where-Object { $_.Name -notmatch $versionPattern })
if ($files.Count -eq 0) {
throw "No release artifacts were found under $BundleRoot."
$found = ($allFiles | ForEach-Object { Get-RelativePath -BasePath $BundleRoot -Path $_.FullName }) -join ", "
throw "No release artifacts for version $TargetVersion were found under $BundleRoot. Found artifacts: $found"
}
if ($ignoredFiles.Count -gt 0) {
Write-Host ""
Write-Host "Ignoring bundle artifacts that do not match version ${TargetVersion}:"
foreach ($ignored in $ignoredFiles) {
Write-Host (" - " + (Get-RelativePath -BasePath $BundleRoot -Path $ignored.FullName))
}
}
$copied = @()
@@ -587,7 +634,7 @@ try {
Invoke-ReleaseBuild
$releaseDir = New-ReleaseDirectory -TargetVersion $targetVersion
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir)
$artifacts = @(Copy-ReleaseArtifacts -ReleaseDir $releaseDir -TargetVersion $targetVersion)
Write-Checksums -ReleaseDir $releaseDir -Files $artifacts | Out-Null
Write-ReleaseMetadata -ReleaseDir $releaseDir -TargetVersion $targetVersion -Artifacts $artifacts

1
src-tauri/Cargo.lock generated
View File

@@ -2317,6 +2317,7 @@ name = "proxywarden"
version = "1.0.1"
dependencies = [
"base64 0.22.1",
"percent-encoding",
"reqwest 0.12.28",
"serde",
"serde_json",

View File

@@ -19,5 +19,6 @@ serde_json = "1"
tauri-plugin-dialog = "2.7.1"
base64 = "0.22"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "socks"] }
percent-encoding = "2"
url = "2"
uuid = { version = "1", features = ["v4"] }

View File

@@ -1,4 +1,6 @@
use percent_encoding::percent_decode_str;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
@@ -159,6 +161,12 @@ impl LocalSingBoxConfig {
.as_deref()
.map(redact_subscription_url)
}
pub fn normalize_percent_encoded_tags(&mut self) {
if let Some(selected_server_tag) = self.selected_server_tag.as_mut() {
*selected_server_tag = decode_percent_encoded_utf8(selected_server_tag);
}
}
}
impl Default for LocalSingBoxConfig {
@@ -186,6 +194,36 @@ pub struct SubscriptionCache {
pub fetched_at: String,
}
impl SubscriptionCache {
pub fn normalize_percent_encoded_tags(&mut self) {
for server in &mut self.servers {
server.tag = decode_percent_encoded_utf8(&server.tag);
}
let Some(outbounds) = self
.config
.get_mut("outbounds")
.and_then(Value::as_array_mut)
else {
return;
};
for outbound in outbounds {
let Some(decoded_tag) = outbound
.get("tag")
.and_then(Value::as_str)
.map(decode_percent_encoded_utf8)
else {
continue;
};
if let Some(object) = outbound.as_object_mut() {
object.insert("tag".to_string(), Value::String(decoded_tag));
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SubscriptionServer {
pub tag: String,
@@ -274,3 +312,10 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
}
}
}
pub fn decode_percent_encoded_utf8(value: &str) -> String {
percent_decode_str(value)
.decode_utf8()
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| value.to_string())
}

View File

@@ -96,7 +96,10 @@ impl JsonStorage {
}
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
self.read_json_or_default(&self.paths.local_singbox_file)
let mut config: LocalSingBoxConfig =
self.read_json_or_default(&self.paths.local_singbox_file)?;
config.normalize_percent_encoded_tags();
Ok(config)
}
pub fn write_local_singbox_config(&self, config: &LocalSingBoxConfig) -> io::Result<()> {
@@ -104,7 +107,12 @@ impl JsonStorage {
}
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
self.read_optional_json(&self.paths.singbox_subscription_cache_file)
let mut cache = self
.read_optional_json::<SubscriptionCache>(&self.paths.singbox_subscription_cache_file)?;
if let Some(cache) = cache.as_mut() {
cache.normalize_percent_encoded_tags();
}
Ok(cache)
}
pub fn write_singbox_subscription_cache(&self, cache: &SubscriptionCache) -> io::Result<()> {

View File

@@ -1,4 +1,4 @@
use crate::models::{SubscriptionCache, SubscriptionServer};
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
use base64::{engine::general_purpose, Engine};
use serde_json::{json, Map, Value};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -192,7 +192,10 @@ fn parse_vless_url(raw_url: &str) -> Result<Value, SubscriptionError> {
}
let parsed = Url::parse(raw_url).map_err(|_| SubscriptionError::new("Invalid VLESS URL"))?;
let tag = parsed.fragment().unwrap_or("vless-out").to_string();
let tag = parsed
.fragment()
.map(decode_percent_encoded_utf8)
.unwrap_or_else(|| "vless-out".to_string());
let uuid = parsed.username().trim().to_string();
let server = parsed.host_str().map(str::to_string).unwrap_or_default();
let server_port = parsed.port_or_known_default().unwrap_or(443);

View File

@@ -113,6 +113,58 @@ fn missing_local_singbox_config_defaults_to_optional_empty_state() {
cleanup(&root);
}
#[test]
fn reads_percent_encoded_singbox_tags_as_utf8() {
let root = test_root("local-singbox-percent-tags");
let storage = JsonStorage::new(root.clone());
let encoded_tag =
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA";
let decoded_tag = "Умный 🇳🇱->🇷🇺";
storage
.write_local_singbox_config(&LocalSingBoxConfig {
selected_server_tag: Some(encoded_tag.to_string()),
..LocalSingBoxConfig::default()
})
.expect("write local sing-box config");
storage
.write_singbox_subscription_cache(&SubscriptionCache {
config: serde_json::json!({
"outbounds": [
{
"type": "vless",
"tag": encoded_tag,
"server": "nl.example.test",
"server_port": 443
}
]
}),
servers: vec![SubscriptionServer {
tag: encoded_tag.to_string(),
server_type: "vless".to_string(),
server: "nl.example.test".to_string(),
server_port: 443,
}],
user_info: serde_json::Map::new(),
fetched_at: "2026-07-07T10:00:00Z".to_string(),
})
.expect("write subscription cache");
let config = storage
.read_local_singbox_config()
.expect("read local sing-box config");
let cache = storage
.read_singbox_subscription_cache()
.expect("read subscription cache")
.expect("subscription cache");
assert_eq!(config.selected_server_tag, Some(decoded_tag.to_string()));
assert_eq!(cache.servers[0].tag, decoded_tag);
assert_eq!(cache.config["outbounds"][0]["tag"], decoded_tag);
cleanup(&root);
}
#[test]
fn invalid_subscription_cache_falls_back_to_none() {
let root = test_root("invalid-subscription-cache");

View File

@@ -44,6 +44,19 @@ fn parses_base64_vless_link_list() {
assert_eq!(outbound["packet_encoding"], "xudp");
}
#[test]
fn decodes_percent_encoded_vless_fragment_tag() {
let link = sample_vless_link(
"%D0%A3%D0%BC%D0%BD%D1%8B%D0%B9%20%F0%9F%87%B3%F0%9F%87%B1-%3E%F0%9F%87%B7%F0%9F%87%BA",
);
let parsed = parse_subscription_body(&link).expect("vless link should parse");
let outbound = &parsed.config["outbounds"][0];
assert_eq!(parsed.servers[0].tag, "Умный 🇳🇱->🇷🇺");
assert_eq!(outbound["tag"], "Умный 🇳🇱->🇷🇺");
}
#[test]
fn rejects_body_without_supported_outbounds() {
let error = parse_subscription_body(r#"{"outbounds":[{"type":"direct","tag":"direct"}]}"#)