Refactor ProxyWarden routing and settings flow

This commit is contained in:
2026-07-09 11:51:16 +03:00
parent db0c1dede9
commit 1bb795a532
18 changed files with 1018 additions and 210 deletions

View File

@@ -12,12 +12,14 @@ use crate::component_detection::{
proxyfier_component_from_detection, singbox_component_from_detection, DetectedProxyfier,
DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost,
};
use crate::elevated_scripts;
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
SubscriptionCache, SubscriptionServer, Target, TargetInput, TargetKind,
};
use crate::process::command_no_window;
use crate::safe_fs;
use crate::singbox_service::{
build_singbox_setup_status, ensure_safe_singbox_install_dir,
parse_service_command_output as parse_singbox_service_command_output, service_control_script,
@@ -718,54 +720,71 @@ pub fn select_singbox_server(
}
#[tauri::command]
pub fn ping_singbox_server(
pub async fn ping_singbox_server(
state: tauri::State<'_, CommandState>,
input: PingSingBoxServerInputDto,
) -> Result<PingServerResponse, CommandError> {
ping_singbox_server_in_storage(&state.storage(), input)
let storage = state.storage();
tauri::async_runtime::spawn_blocking(move || ping_singbox_server_in_storage(&storage, input))
.await
.map_err(background_task_error)?
}
#[tauri::command]
pub fn ping_all_singbox_servers(
pub async fn ping_all_singbox_servers(
state: tauri::State<'_, CommandState>,
) -> Result<Vec<PingServerResponse>, CommandError> {
ping_all_singbox_servers_in_storage(&state.storage())
let storage = state.storage();
tauri::async_runtime::spawn_blocking(move || ping_all_singbox_servers_in_storage(&storage))
.await
.map_err(background_task_error)?
}
#[tauri::command]
pub fn ping_proxy_target(
pub async fn ping_proxy_target(
input: PingProxyTargetInputDto,
) -> Result<ProxyTargetCheckResponse, CommandError> {
ping_proxy_target_endpoint(input)
tauri::async_runtime::spawn_blocking(move || ping_proxy_target_endpoint(input))
.await
.map_err(background_task_error)?
}
#[tauri::command]
pub fn generate_singbox_config(
pub async fn generate_singbox_config(
state: tauri::State<'_, CommandState>,
) -> Result<GenerateSingBoxConfigResponse, CommandError> {
let detected = detect_singbox_install();
let binary_path = detected
.as_ref()
.map(|detected| detected.executable_path.as_path());
generate_singbox_config_with_services(
&state.storage(),
&SingBoxAdapter::default(),
&SingBoxCommandChecker,
&SystemClock,
binary_path,
)
let storage = state.storage();
tauri::async_runtime::spawn_blocking(move || {
let detected = detect_singbox_install();
let binary_path = detected
.as_ref()
.map(|detected| detected.executable_path.as_path());
generate_singbox_config_with_services(
&storage,
&SingBoxAdapter::default(),
&SingBoxCommandChecker,
&SystemClock,
binary_path,
)
})
.await
.map_err(background_task_error)?
}
#[tauri::command]
pub fn apply_profiles(
pub async fn apply_profiles(
state: tauri::State<'_, CommandState>,
) -> Result<ApplyProfilesResponse, CommandError> {
let storage = state.storage();
let adapter = ProxiFyreAdapter::default();
let helper = DetectedProxyApplyHelper::system();
let clock = SystemClock;
tauri::async_runtime::spawn_blocking(move || {
let adapter = ProxiFyreAdapter::default();
let helper = DetectedProxyApplyHelper::system();
let clock = SystemClock;
apply_profiles_with_services(&storage, &adapter, &helper, &clock)
apply_profiles_with_services(&storage, &adapter, &helper, &clock)
})
.await
.map_err(background_task_error)?
}
#[tauri::command]
@@ -2193,11 +2212,7 @@ fn write_elevated_singbox_service_script(
config_source: Option<&Path>,
config_target: Option<&Path>,
) -> Result<PathBuf, CommandError> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
let script_path = env::temp_dir().join(format!("proxywarden-singbox-service-{nonce}.ps1"));
let script_path = elevated_scripts::temp_script_path("proxywarden-singbox-service");
let script =
elevated_singbox_service_script(action, service_name, config_source, config_target);
@@ -2374,10 +2389,6 @@ fn run_elevated_singbox_package_script(
installer_args: Vec<String>,
artifact_dir: &Path,
) -> Result<(), CommandError> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
fs::create_dir_all(artifact_dir).map_err(|error| {
CommandError::new(
action.error_code(),
@@ -2388,18 +2399,12 @@ fn run_elevated_singbox_package_script(
)
})?;
let installer_path = artifact_dir.join(format!(
"proxywarden-singbox-{}-{nonce}.ps1",
action.file_label()
));
let runner_path = artifact_dir.join(format!(
"proxywarden-singbox-{}-{nonce}.runner.ps1",
action.file_label()
));
let result_path = artifact_dir.join(format!(
"proxywarden-singbox-{}-{nonce}.log",
action.file_label()
));
let prefix = format!("proxywarden-singbox-{}", action.file_label());
let installer_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
let runner_path =
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.runner"), "ps1");
let result_path =
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
write_powershell_script(&installer_path, installer_body).map_err(|error| {
CommandError::new(
@@ -2735,10 +2740,7 @@ fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> ResolvedAppDt
}
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(storage_error)?;
}
fs::write(path, contents).map_err(storage_error)
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
}
fn open_file_or_select(path: &Path) -> Result<(), CommandError> {
@@ -3041,11 +3043,7 @@ fn write_elevated_service_script(
action: ServiceControlAction,
service_names: &[String],
) -> Result<PathBuf, CommandError> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
let script_path = env::temp_dir().join(format!("proxywarden-proxifyre-service-{nonce}.ps1"));
let script_path = elevated_scripts::temp_script_path("proxywarden-proxifyre-service");
let script = elevated_service_script(action, service_names);
write_powershell_script(&script_path, &script).map_err(|error| {
@@ -3405,10 +3403,6 @@ fn run_elevated_package_script(
body: String,
artifact_dir: &Path,
) -> Result<(), CommandError> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
fs::create_dir_all(artifact_dir).map_err(|error| {
CommandError::new(
action.error_code(),
@@ -3418,14 +3412,10 @@ fn run_elevated_package_script(
),
)
})?;
let script_path = artifact_dir.join(format!(
"proxywarden-proxifyre-{}-{nonce}.ps1",
action.file_label()
));
let result_path = artifact_dir.join(format!(
"proxywarden-proxifyre-{}-{nonce}.log",
action.file_label()
));
let prefix = format!("proxywarden-proxifyre-{}", action.file_label());
let script_path = elevated_scripts::artifact_path(artifact_dir, &prefix, "ps1");
let result_path =
elevated_scripts::artifact_path(artifact_dir, &format!("{prefix}.result"), "log");
let script = wrap_elevated_package_script(&body, &result_path);
write_powershell_script(&script_path, &script).map_err(|error| {
@@ -3845,46 +3835,17 @@ fn apply_to_detected_proxyfier(
return staged_apply_result(request);
};
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err(
|error| {
CommandError::new(
"proxyfier_apply_failed",
format!(
"Не удалось создать папку конфига ProxiFyre '{}': {error}",
parent.display()
),
)
})?;
}
if config_path.exists() {
let backup_path = config_path.with_file_name(format!(
"{}.bak",
config_path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("app-config.json")
));
fs::copy(config_path, backup_path).map_err(|error| {
CommandError::new(
"proxyfier_apply_failed",
format!(
"Не удалось создать backup текущего конфига ProxiFyre '{}': {error}",
"Не удалось безопасно записать конфиг ProxiFyre '{}': {error}",
config_path.display()
),
)
})?;
}
fs::write(config_path, request.config_contents).map_err(|error| {
CommandError::new(
"proxyfier_apply_failed",
format!(
"Не удалось записать конфиг ProxiFyre '{}': {error}",
config_path.display()
),
)
})?;
},
)?;
Ok(HelperApplyResult {
success: true,

View File

@@ -0,0 +1,19 @@
use std::env;
use std::path::{Path, PathBuf};
pub fn temp_script_path(prefix: &str) -> PathBuf {
env::temp_dir().join(unique_file_name(prefix, "ps1"))
}
pub fn artifact_path(artifact_dir: &Path, prefix: &str, extension: &str) -> PathBuf {
artifact_dir.join(unique_file_name(prefix, extension))
}
fn unique_file_name(prefix: &str, extension: &str) -> String {
let extension = extension.trim_start_matches('.');
format!(
"{prefix}-{}.{}",
uuid::Uuid::new_v4().hyphenated(),
extension
)
}

View File

@@ -1,9 +1,11 @@
pub mod activity;
pub mod commands;
pub mod component_detection;
pub mod elevated_scripts;
pub mod helper;
pub mod models;
pub mod process;
pub mod safe_fs;
pub mod singbox_service;
pub mod storage;
pub mod subscription;

View File

@@ -1,6 +1,7 @@
use percent_encoding::percent_decode_str;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_PORT: u16 = 1080;
@@ -293,24 +294,22 @@ pub fn redact_subscription_url(raw_url: &str) -> String {
return String::new();
}
match trimmed.split_once("://") {
Some((scheme, rest)) => {
let host = rest
.split(['/', '?', '#'])
.next()
.filter(|value| !value.is_empty())
.unwrap_or("subscription");
format!("{scheme}://{host}/...")
}
None => {
let visible = trimmed.chars().take(18).collect::<String>();
if trimmed.chars().count() <= 18 {
"***".to_string()
} else {
format!("{visible}...")
}
}
}
let Ok(parsed) = Url::parse(trimmed) else {
return "***".to_string();
};
let host = parsed.host_str().unwrap_or("subscription");
let host = if host.contains(':') && !host.starts_with('[') {
format!("[{host}]")
} else {
host.to_string()
};
let port = parsed
.port()
.map(|port| format!(":{port}"))
.unwrap_or_default();
format!("{}://{}{}/...", parsed.scheme(), host, port)
}
pub fn decode_percent_encoded_utf8(value: &str) -> String {

53
src-tauri/src/safe_fs.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
pub fn backup_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "bak")
}
pub fn corrupt_path(path: &Path) -> PathBuf {
sibling_with_suffix(
path,
&format!("corrupt.{}", uuid::Uuid::new_v4().hyphenated()),
)
}
pub fn temp_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, &format!("tmp.{}", uuid::Uuid::new_v4().hyphenated()))
}
pub fn write_with_backup(path: &Path, contents: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let temp_path = temp_path(path);
fs::write(&temp_path, contents)?;
let backup_path = backup_path(path);
if path.exists() {
fs::copy(path, &backup_path)?;
fs::remove_file(path)?;
}
match fs::rename(&temp_path, path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp_path);
if !path.exists() && backup_path.exists() {
let _ = fs::copy(&backup_path, path);
}
Err(error)
}
}
}
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("proxywarden-file");
path.with_file_name(format!("{file_name}.{suffix}"))
}

View File

@@ -2,6 +2,7 @@ use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
use crate::models::{
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
};
use crate::safe_fs;
use serde::{de::DeserializeOwned, Serialize};
use std::fs;
use std::io::{self, ErrorKind};
@@ -144,10 +145,9 @@ impl JsonStorage {
T: DeserializeOwned + Default,
{
match fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str(&contents) {
Ok(value) => Ok(value),
Err(_) => Ok(T::default()),
},
Ok(contents) => {
parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error))
}
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(error),
}
@@ -167,7 +167,9 @@ impl JsonStorage {
T: DeserializeOwned,
{
match fs::read_to_string(path) {
Ok(contents) => Ok(serde_json::from_str(&contents).ok()),
Ok(contents) => parse_json(path, &contents)
.map(Some)
.or_else(|error| recover_corrupt_json(path, error).map(Some)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
@@ -181,40 +183,72 @@ impl Default for JsonStorage {
}
pub fn backup_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "bak")
}
fn temp_path(path: &Path) -> PathBuf {
sibling_with_suffix(path, "tmp")
}
fn sibling_with_suffix(path: &Path, suffix: &str) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("storage.json");
path.with_file_name(format!("{file_name}.{suffix}"))
safe_fs::backup_path(path)
}
fn write_atomic(path: &Path, contents: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
safe_fs::write_with_backup(path, contents)
}
fn parse_json<T>(path: &Path, contents: &str) -> io::Result<T>
where
T: DeserializeOwned,
{
serde_json::from_str(contents).map_err(|error| {
io::Error::new(
ErrorKind::InvalidData,
format!("Invalid JSON in '{}': {error}", path.display()),
)
})
}
fn recover_corrupt_json<T>(path: &Path, parse_error: io::Error) -> io::Result<T>
where
T: DeserializeOwned,
{
let corrupt_path = safe_fs::corrupt_path(path);
move_corrupt_file(path, &corrupt_path)?;
let backup_path = backup_path(path);
if backup_path.exists() {
let backup_contents = fs::read_to_string(&backup_path)?;
match parse_json(&backup_path, &backup_contents) {
Ok(value) => {
fs::copy(&backup_path, path)?;
Ok(value)
}
Err(backup_error) => Err(io::Error::new(
ErrorKind::InvalidData,
format!(
"Invalid JSON in '{}'; corrupt file moved to '{}'; backup '{}' could not be restored: {backup_error}; original error: {parse_error}",
path.display(),
corrupt_path.display(),
backup_path.display()
),
)),
}
} else {
Err(io::Error::new(
ErrorKind::InvalidData,
format!(
"Invalid JSON in '{}'; corrupt file moved to '{}'; no valid backup available: {parse_error}",
path.display(),
corrupt_path.display()
),
))
}
}
let temp_path = temp_path(path);
fs::write(&temp_path, contents)?;
if path.exists() {
fs::copy(path, backup_path(path))?;
fs::remove_file(path)?;
}
match fs::rename(&temp_path, path) {
fn move_corrupt_file(path: &Path, corrupt_path: &Path) -> io::Result<()> {
match fs::rename(path, corrupt_path) {
Ok(()) => Ok(()),
Err(error) => {
let _ = fs::remove_file(&temp_path);
Err(error)
Err(rename_error) => {
fs::copy(path, corrupt_path)?;
fs::remove_file(path)?;
if !corrupt_path.exists() {
return Err(rename_error);
}
Ok(())
}
}
}

View File

@@ -1,11 +1,16 @@
use crate::models::{decode_percent_encoded_utf8, SubscriptionCache, SubscriptionServer};
use base64::{engine::general_purpose, Engine};
use reqwest::redirect;
use serde_json::{json, Map, Value};
use std::net::{IpAddr, Ipv6Addr};
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use url::Url;
const SUPPORTED_PROXY_TYPES: &[&str] = &["vless", "vmess", "trojan", "shadowsocks", "hysteria2"];
const DEFAULT_APP_NAME: &str = "ProxyWarden";
const SUBSCRIPTION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const SUBSCRIPTION_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionError {
@@ -34,6 +39,11 @@ pub struct ParsedSubscription {
pub servers: Vec<SubscriptionServer>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SubscriptionFetchPolicy {
pub allow_unsafe_local_urls: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubscriptionFetchIdentity {
pub device_hwid: Option<String>,
@@ -134,16 +144,35 @@ pub fn fetch_subscription(url: &str) -> Result<SubscriptionCache, SubscriptionEr
pub fn fetch_subscription_with_identity(
url: &str,
identity: &SubscriptionFetchIdentity,
) -> Result<SubscriptionCache, SubscriptionError> {
fetch_subscription_with_identity_and_policy(url, identity, SubscriptionFetchPolicy::default())
}
pub fn fetch_subscription_with_identity_and_policy(
url: &str,
identity: &SubscriptionFetchIdentity,
policy: SubscriptionFetchPolicy,
) -> Result<SubscriptionCache, SubscriptionError> {
let parsed_url =
Url::parse(url).map_err(|_| SubscriptionError::new("Invalid subscription URL"))?;
if !matches!(parsed_url.scheme(), "http" | "https") {
return Err(SubscriptionError::new(
"Subscription URL must use http or https",
));
}
validate_subscription_fetch_url(&parsed_url, policy)?;
let mut request = reqwest::blocking::Client::new().get(parsed_url);
let redirect_policy = redirect::Policy::custom(move |attempt| {
if validate_subscription_fetch_url(attempt.url(), policy).is_ok() {
attempt.follow()
} else {
attempt.stop()
}
});
let client = reqwest::blocking::Client::builder()
.connect_timeout(SUBSCRIPTION_CONNECT_TIMEOUT)
.timeout(SUBSCRIPTION_REQUEST_TIMEOUT)
.redirect(redirect_policy)
.build()
.map_err(|error| {
SubscriptionError::new(format!("Subscription client setup failed: {error}"))
})?;
let mut request = client.get(parsed_url);
for (name, value) in identity.request_headers_without_device_hwid() {
request = request.header(name, value);
@@ -189,6 +218,69 @@ pub fn fetch_subscription_with_identity(
})
}
fn validate_subscription_fetch_url(
parsed_url: &Url,
policy: SubscriptionFetchPolicy,
) -> Result<(), SubscriptionError> {
if !matches!(parsed_url.scheme(), "http" | "https") {
return Err(SubscriptionError::new(
"Subscription URL must use http or https",
));
}
if !policy.allow_unsafe_local_urls && is_unsafe_subscription_host(parsed_url) {
return Err(SubscriptionError::new(
"Subscription URL host is local, private, link-local, multicast, or metadata-only",
));
}
Ok(())
}
fn is_unsafe_subscription_host(parsed_url: &Url) -> bool {
let Some(host) = parsed_url.host_str() else {
return true;
};
let host = host.trim_matches(['[', ']']).to_ascii_lowercase();
if matches!(host.as_str(), "localhost" | "metadata.google.internal")
|| host.ends_with(".localhost")
{
return true;
}
host.parse::<IpAddr>().is_ok_and(is_unsafe_ip)
}
fn is_unsafe_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
ip.is_loopback()
|| ip.is_private()
|| ip.is_link_local()
|| ip.is_multicast()
|| ip.is_broadcast()
|| ip.is_unspecified()
|| ip.octets() == [169, 254, 169, 254]
}
IpAddr::V6(ip) => {
ip.is_loopback()
|| ip.is_unspecified()
|| ip.is_multicast()
|| is_unique_local_ipv6(ip)
|| is_unicast_link_local_ipv6(ip)
}
}
}
fn is_unique_local_ipv6(ip: Ipv6Addr) -> bool {
(ip.segments()[0] & 0xfe00) == 0xfc00
}
fn is_unicast_link_local_ipv6(ip: Ipv6Addr) -> bool {
(ip.segments()[0] & 0xffc0) == 0xfe80
}
fn parse_link_subscription(body: &str) -> Result<Value, SubscriptionError> {
let decoded = maybe_decode_base64(body);
let links = decoded

View File

@@ -13,16 +13,16 @@
"windows": [
{
"title": "ProxyWarden",
"width": 820,
"width": 920,
"height": 760,
"minWidth": 820,
"maxWidth": 820,
"minWidth": 760,
"maxWidth": 1200,
"minHeight": 560,
"resizable": true
}
],
"security": {
"csp": null
"csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost; img-src 'self' asset: http://asset.localhost data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; script-src 'self'"
}
},
"bundle": {

View File

@@ -400,11 +400,14 @@ fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
let applied =
fs::read_to_string(install_dir.join("app-config.json")).expect("read applied app-config");
let backup =
fs::read_to_string(install_dir.join("app-config.json.bak")).expect("read backup config");
assert!(result.success);
assert!(result.changed);
assert_eq!(result.action, "proxifyre.apply-detected-config");
assert_eq!(applied, r#"{"proxies":[]}"#);
assert_eq!(backup, "{}");
assert!(install_dir.join("app-config.json.bak").exists());
cleanup(&root);

View File

@@ -159,7 +159,7 @@ fn reads_percent_encoded_singbox_tags_as_utf8() {
}
#[test]
fn invalid_subscription_cache_falls_back_to_none() {
fn invalid_subscription_cache_without_backup_returns_error_and_moves_corrupt_file() {
let root = test_root("invalid-subscription-cache");
let storage = JsonStorage::new(root.clone());
fs::create_dir_all(&storage.paths().state_dir).expect("create state dir");
@@ -169,27 +169,62 @@ fn invalid_subscription_cache_falls_back_to_none() {
)
.expect("write invalid cache");
assert_eq!(
storage
.read_singbox_subscription_cache()
.expect("invalid cache fallback"),
None
);
let error = storage
.read_singbox_subscription_cache()
.expect_err("invalid cache should not silently fallback");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(!storage.paths().singbox_subscription_cache_file.exists());
assert!(has_corrupt_sibling(
&storage.paths().singbox_subscription_cache_file
));
cleanup(&root);
}
#[test]
fn invalid_json_falls_back_to_empty_collection() {
let root = test_root("invalid-json");
fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() {
let root = test_root("invalid-json-no-backup");
let storage = JsonStorage::new(root.clone());
fs::create_dir_all(&storage.paths().config_dir).expect("create config dir");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("write invalid json");
let error = storage
.read_profiles()
.expect_err("invalid profiles should not silently fallback");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(!storage.paths().profiles_file.exists());
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
cleanup(&root);
}
#[test]
fn invalid_json_recovers_from_valid_backup() {
let root = test_root("invalid-json-valid-backup");
let storage = JsonStorage::new(root.clone());
let backup_profiles = vec![sample_profile("backup")];
let current_profiles = vec![sample_profile("current")];
storage
.write_profiles(&backup_profiles)
.expect("write first profiles");
storage
.write_profiles(&current_profiles)
.expect("write second profiles");
fs::write(&storage.paths().profiles_file, "{not valid json").expect("corrupt live json");
let recovered = storage
.read_profiles()
.expect("invalid profiles should recover from valid backup");
assert_eq!(recovered, backup_profiles);
assert_eq!(
storage.read_profiles().expect("invalid profiles fallback"),
Vec::<Profile>::new()
storage.read_profiles().expect("restored live profiles"),
backup_profiles
);
assert!(has_corrupt_sibling(&storage.paths().profiles_file));
cleanup(&root);
}
@@ -279,6 +314,26 @@ fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
fs::write(path, contents).expect("write json");
}
fn has_corrupt_sibling(path: &Path) -> bool {
let Some(parent) = path.parent() else {
return false;
};
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
return false;
};
let prefix = format!("{file_name}.corrupt.");
fs::read_dir(parent)
.expect("read sibling dir")
.filter_map(Result::ok)
.any(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.starts_with(&prefix))
})
}
fn sample_profile(id: &str) -> Profile {
Profile {
id: id.to_string(),

View File

@@ -2,6 +2,7 @@ use base64::{engine::general_purpose, Engine};
use proxywarden_lib::models::redact_subscription_url;
use proxywarden_lib::subscription::{
self, parse_subscription_body, parse_user_info, SubscriptionFetchIdentity,
SubscriptionFetchPolicy,
};
use std::io::{Read, Write};
use std::net::TcpListener;
@@ -91,6 +92,25 @@ fn rejects_invalid_or_non_http_subscription_url_before_network() {
assert!(unsupported.message.contains("http or https"));
}
#[test]
fn rejects_unsafe_local_subscription_urls_before_network() {
for url in [
"http://127.0.0.1:9/subscription",
"http://localhost/subscription",
"http://169.254.169.254/latest/meta-data",
"http://192.168.0.1/subscription",
"http://[::1]/subscription",
] {
let error = subscription::fetch_subscription(url)
.expect_err("unsafe local URL should fail before request");
assert!(
error.message.contains("local, private"),
"unexpected error for {url}: {}",
error.message
);
}
}
#[test]
fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind local test listener");
@@ -129,8 +149,14 @@ fn fetch_subscription_sends_device_hwid_header_when_identity_is_set() {
let mut identity = SubscriptionFetchIdentity::with_device_hwid(Some("hwid-abc123"));
identity.device_os_version = Some("Windows 11 Pro | 25H2 | build 26200.8655".to_string());
let cache = subscription::fetch_subscription_with_identity(&url, &identity)
.expect("fetch subscription through local test server");
let cache = subscription::fetch_subscription_with_identity_and_policy(
&url,
&identity,
SubscriptionFetchPolicy {
allow_unsafe_local_urls: true,
},
)
.expect("fetch subscription through local test server");
let request = request_thread.join().expect("request thread");
assert_eq!(cache.servers[0].tag, "nl-1");
@@ -151,7 +177,13 @@ fn redacts_subscription_url_for_display() {
);
assert_eq!(
redact_subscription_url("vless://uuid@example.test"),
"vless://uuid@example.test/..."
"vless://example.test/..."
);
assert_eq!(
redact_subscription_url(
"https://user:password@sub.example.test:8443/path?token=secret#frag"
),
"https://sub.example.test:8443/..."
);
}