Refactor application structure and simplify implementation
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::adapters::singbox::{
|
||||
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
||||
};
|
||||
use proxywarden_lib::apply_flow::{
|
||||
apply_configuration, ApplyConfigurationInput, ApplyPhaseStatus, ApplyRouteMode, ApplyServices,
|
||||
};
|
||||
use proxywarden_lib::commands::{
|
||||
Clock, CommandError, HelperApplyRequest, HelperApplyResult, ProxyApplyHelper,
|
||||
};
|
||||
use proxywarden_lib::component_detection::{DetectedProxyfier, ProxyfierEngine};
|
||||
use proxywarden_lib::models::{
|
||||
LocalSingBoxConfig, Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType,
|
||||
Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetInput,
|
||||
TargetKind,
|
||||
};
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use std::{cell::Cell, fs, path::Path};
|
||||
|
||||
#[test]
|
||||
fn external_apply_commits_one_source_state_without_service_control() {
|
||||
let fixture = ApplyFixture::new("external-success");
|
||||
fixture.seed_old_state();
|
||||
let helper = RecordingHelper::success();
|
||||
|
||||
let result =
|
||||
run_apply(&fixture.storage, external_input(), &helper).expect("preflight should succeed");
|
||||
|
||||
assert!(result.success);
|
||||
assert!(!result.partial_state);
|
||||
assert_eq!(helper.calls.get(), 1);
|
||||
assert!(result.phases.iter().any(|phase| {
|
||||
phase.id == "service-control" && phase.status == ApplyPhaseStatus::Skipped
|
||||
}));
|
||||
let profiles = fixture.storage.read_profiles().expect("read profiles");
|
||||
let targets = fixture.storage.read_targets().expect("read targets");
|
||||
assert!(profiles
|
||||
.iter()
|
||||
.any(|profile| profile.id == "main-profile" && profile.enabled));
|
||||
assert!(profiles
|
||||
.iter()
|
||||
.any(|profile| profile.id == "legacy" && !profile.enabled));
|
||||
assert!(targets.iter().any(|target| {
|
||||
target.id == "main-proxy" && target.host == "proxy.example.test" && target.port == 1080
|
||||
}));
|
||||
assert!(Path::new(&result.generated_config_path).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preflight_failure_does_not_write_source_or_call_helper() {
|
||||
let fixture = ApplyFixture::new("preflight-failure");
|
||||
fixture.seed_old_state();
|
||||
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
|
||||
let before_targets = fixture.storage.read_targets().expect("targets before");
|
||||
let helper = RecordingHelper::success();
|
||||
let mut input = external_input();
|
||||
input.external_target.as_mut().expect("target").host =
|
||||
"socks5://unsafe.example.test".to_string();
|
||||
|
||||
let error = run_apply(&fixture.storage, input, &helper)
|
||||
.expect_err("invalid target should fail before writes");
|
||||
|
||||
assert_eq!(error.code(), "validation_failed");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
assert_eq!(
|
||||
fixture.storage.read_profiles().expect("profiles after"),
|
||||
before_profiles
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.storage.read_targets().expect("targets after"),
|
||||
before_targets
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_blocks_apply_when_proxifyre_is_not_detected() {
|
||||
let fixture = ApplyFixture::new("missing-proxifyre");
|
||||
fixture.seed_old_state();
|
||||
let helper = RecordingHelper::success();
|
||||
let proxy_adapter = ProxiFyreAdapter::default();
|
||||
let singbox_adapter = SingBoxAdapter::default();
|
||||
|
||||
let error = apply_configuration(
|
||||
&fixture.storage,
|
||||
external_input(),
|
||||
ApplyServices {
|
||||
proxy_adapter: &proxy_adapter,
|
||||
singbox_adapter: &singbox_adapter,
|
||||
checker: &NoopChecker,
|
||||
helper: &helper,
|
||||
clock: &FixedClock,
|
||||
detected_proxyfier: None,
|
||||
detected_singbox: None,
|
||||
},
|
||||
)
|
||||
.expect_err("backend must not trust frontend readiness");
|
||||
|
||||
assert_eq!(error.code(), "proxifyre_not_found");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_command_contract_uses_camel_case_nested_dtos() {
|
||||
let input: ApplyConfigurationInput = serde_json::from_value(serde_json::json!({
|
||||
"routeMode": "external",
|
||||
"profile": {
|
||||
"id": "main-profile",
|
||||
"name": "Main",
|
||||
"enabled": true,
|
||||
"targetId": "main-proxy",
|
||||
"protocols": ["TCP"],
|
||||
"items": [{ "type": "process", "value": "Discord.exe" }]
|
||||
},
|
||||
"externalTarget": {
|
||||
"id": "main-proxy",
|
||||
"name": "Proxy",
|
||||
"kind": "external",
|
||||
"protocol": "socks5",
|
||||
"host": "proxy.example.test",
|
||||
"port": 1080
|
||||
},
|
||||
"disableOtherProfiles": true
|
||||
}))
|
||||
.expect("typed Tauri input should deserialize");
|
||||
|
||||
assert_eq!(input.profile.target_id, "main-proxy");
|
||||
assert_eq!(input.profile.items[0].item_type, "process");
|
||||
assert_eq!(
|
||||
input.external_target.expect("target").host,
|
||||
"proxy.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_failure_rolls_back_source_and_generated_artifact() {
|
||||
let fixture = ApplyFixture::new("helper-rollback");
|
||||
fixture.seed_old_state();
|
||||
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
|
||||
let before_targets = fixture.storage.read_targets().expect("targets before");
|
||||
let generated_path = fixture
|
||||
.storage
|
||||
.paths()
|
||||
.generated_dir
|
||||
.join("proxifyre-app-config.json");
|
||||
fs::create_dir_all(generated_path.parent().expect("generated parent"))
|
||||
.expect("create generated dir");
|
||||
fs::write(&generated_path, b"old-generated").expect("seed generated config");
|
||||
|
||||
let helper = RecordingHelper::failure();
|
||||
let result = run_apply(&fixture.storage, external_input(), &helper)
|
||||
.expect("runtime failure should return phase result");
|
||||
|
||||
assert!(!result.success);
|
||||
assert!(!result.partial_state);
|
||||
assert_eq!(result.error_code.as_deref(), Some("fixture_apply_failed"));
|
||||
assert!(result
|
||||
.phases
|
||||
.iter()
|
||||
.any(|phase| phase.status == ApplyPhaseStatus::RolledBack));
|
||||
assert_eq!(
|
||||
fixture.storage.read_profiles().expect("profiles after"),
|
||||
before_profiles
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.storage.read_targets().expect("targets after"),
|
||||
before_targets
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&generated_path).expect("generated after"),
|
||||
b"old-generated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_apply_with_missing_running_service_stops_at_preflight() {
|
||||
let fixture = ApplyFixture::new("local-service-preflight");
|
||||
fixture.seed_old_state();
|
||||
fixture
|
||||
.storage
|
||||
.write_local_singbox_config(&LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
selected_server_id: Some("fixture-server".to_string()),
|
||||
selected_server_tag: Some("fixture".to_string()),
|
||||
..LocalSingBoxConfig::default()
|
||||
})
|
||||
.expect("write local config");
|
||||
fixture
|
||||
.storage
|
||||
.write_singbox_subscription_cache(&SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [{
|
||||
"type": "vless",
|
||||
"tag": "fixture",
|
||||
"server": "edge.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
}]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "fixture-server".to_string(),
|
||||
tag: "fixture".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "edge.example.test".to_string(),
|
||||
server_port: 443,
|
||||
}],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "fixture".to_string(),
|
||||
})
|
||||
.expect("write cache");
|
||||
let helper = RecordingHelper::success();
|
||||
let before_profiles = fixture.storage.read_profiles().expect("profiles before");
|
||||
|
||||
let error = run_apply(
|
||||
&fixture.storage,
|
||||
ApplyConfigurationInput {
|
||||
route_mode: ApplyRouteMode::LocalSingbox,
|
||||
profile: profile_input(),
|
||||
external_target: None,
|
||||
disable_other_profiles: true,
|
||||
},
|
||||
&helper,
|
||||
)
|
||||
.expect_err("stopped/missing Local sing-box must block preflight");
|
||||
|
||||
assert_eq!(error.code(), "proxifyre_preflight_failed");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
assert_eq!(
|
||||
fixture.storage.read_profiles().expect("profiles after"),
|
||||
before_profiles
|
||||
);
|
||||
}
|
||||
|
||||
fn external_input() -> ApplyConfigurationInput {
|
||||
ApplyConfigurationInput {
|
||||
route_mode: ApplyRouteMode::External,
|
||||
profile: profile_input(),
|
||||
external_target: Some(TargetInput {
|
||||
id: Some("main-proxy".to_string()),
|
||||
name: "Основной прокси".to_string(),
|
||||
kind: "external".to_string(),
|
||||
protocol: "socks5".to_string(),
|
||||
host: "proxy.example.test".to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
}),
|
||||
disable_other_profiles: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn profile_input() -> ProfileInput {
|
||||
ProfileInput {
|
||||
id: Some("main-profile".to_string()),
|
||||
name: "Приложения через прокси".to_string(),
|
||||
enabled: true,
|
||||
target_id: String::new(),
|
||||
protocols: vec!["TCP".to_string(), "UDP".to_string()],
|
||||
items: vec![ProfileItemInput {
|
||||
item_type: "process".to_string(),
|
||||
value: "Discord.exe".to_string(),
|
||||
recursive: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn run_apply(
|
||||
storage: &JsonStorage,
|
||||
input: ApplyConfigurationInput,
|
||||
helper: &dyn ProxyApplyHelper,
|
||||
) -> Result<
|
||||
proxywarden_lib::apply_flow::ApplyConfigurationResult,
|
||||
proxywarden_lib::apply_flow::ApplyFlowError,
|
||||
> {
|
||||
let proxy_adapter = ProxiFyreAdapter::default();
|
||||
let singbox_adapter = SingBoxAdapter::default();
|
||||
apply_configuration(
|
||||
storage,
|
||||
input,
|
||||
ApplyServices {
|
||||
proxy_adapter: &proxy_adapter,
|
||||
singbox_adapter: &singbox_adapter,
|
||||
checker: &NoopChecker,
|
||||
helper,
|
||||
clock: &FixedClock,
|
||||
detected_proxyfier: Some(test_proxyfier()),
|
||||
detected_singbox: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn test_proxyfier() -> DetectedProxyfier {
|
||||
DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: r"C:\Program Files\ProxyWarden\components\ProxiFyre".into(),
|
||||
executable_path: r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe".into(),
|
||||
config_path: Some(
|
||||
r"C:\Program Files\ProxyWarden\components\ProxiFyre\app-config.json".into(),
|
||||
),
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingHelper {
|
||||
calls: Cell<usize>,
|
||||
succeed: bool,
|
||||
}
|
||||
|
||||
impl RecordingHelper {
|
||||
fn success() -> Self {
|
||||
Self {
|
||||
calls: Cell::new(0),
|
||||
succeed: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn failure() -> Self {
|
||||
Self {
|
||||
calls: Cell::new(0),
|
||||
succeed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyApplyHelper for RecordingHelper {
|
||||
fn apply_proxy_config(
|
||||
&self,
|
||||
_request: HelperApplyRequest<'_>,
|
||||
) -> Result<HelperApplyResult, CommandError> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
if self.succeed {
|
||||
Ok(HelperApplyResult {
|
||||
success: true,
|
||||
changed: true,
|
||||
action: "apply".to_string(),
|
||||
message: "fixture applied".to_string(),
|
||||
})
|
||||
} else {
|
||||
Err(CommandError {
|
||||
code: "fixture_apply_failed".to_string(),
|
||||
message: "fixture helper failed".to_string(),
|
||||
details: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopChecker;
|
||||
|
||||
impl SingBoxConfigChecker for NoopChecker {
|
||||
fn check_config(
|
||||
&self,
|
||||
_binary_path: &Path,
|
||||
_config_json: &str,
|
||||
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
||||
Ok(SingBoxCheckResult {
|
||||
checked: true,
|
||||
success: true,
|
||||
message: "fixture valid".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FixedClock;
|
||||
|
||||
impl Clock for FixedClock {
|
||||
fn now(&self) -> String {
|
||||
"2026-07-11T00:00:00Z".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
struct ApplyFixture {
|
||||
root: std::path::PathBuf,
|
||||
storage: JsonStorage,
|
||||
}
|
||||
|
||||
impl ApplyFixture {
|
||||
fn new(label: &str) -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"proxywarden-apply-flow-{label}-{}",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
Self {
|
||||
storage: JsonStorage::new(root.clone()),
|
||||
root,
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_old_state(&self) {
|
||||
self.storage
|
||||
.write_profiles(&[Profile {
|
||||
id: "legacy".to_string(),
|
||||
name: "Legacy".to_string(),
|
||||
enabled: true,
|
||||
target_id: "legacy-target".to_string(),
|
||||
protocols: vec![Protocol::Tcp],
|
||||
items: vec![ProfileItem {
|
||||
item_type: ProfileItemType::Process,
|
||||
value: "legacy".to_string(),
|
||||
recursive: false,
|
||||
}],
|
||||
}])
|
||||
.expect("seed profiles");
|
||||
self.storage
|
||||
.write_targets(&[Target {
|
||||
id: "legacy-target".to_string(),
|
||||
name: "Legacy".to_string(),
|
||||
kind: TargetKind::External,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host: "legacy.example.test".to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
}])
|
||||
.expect("seed targets");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ApplyFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use proxywarden_lib::models::{
|
||||
self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType,
|
||||
Protocol, ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::proxifyre_ownership::ManagedProxiFyreOwnership;
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -358,7 +359,7 @@ fn proxifyre_uninstall_script_parses_as_powershell() {
|
||||
};
|
||||
|
||||
let script = commands::wrap_elevated_package_script(
|
||||
&commands::uninstall_proxifyre_script(Some(&detected)),
|
||||
&commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true)),
|
||||
&root.join("uninstall.log"),
|
||||
);
|
||||
let script_path = root.join("uninstall.ps1");
|
||||
@@ -397,8 +398,13 @@ fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() {
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
};
|
||||
let script = commands::uninstall_proxifyre_script(Some(&detected));
|
||||
let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true));
|
||||
|
||||
assert!(script.contains("function Find-ManagedProxiFyreService"));
|
||||
assert!(script.contains("Get-CimInstance Win32_Service"));
|
||||
assert!(script.contains("[StringComparison]::OrdinalIgnoreCase"));
|
||||
assert!(!script.contains("function Find-ProxiFyreService"));
|
||||
assert!(!script.contains("Where-Object { $_.Name -match 'ProxiFyre|Proxifyre'"));
|
||||
assert!(script.contains("function Resolve-MsiProductCode($program, [string]$label)"));
|
||||
assert!(script.contains("Отказываюсь запускать произвольный UninstallString"));
|
||||
assert!(script.contains("Start-Process -FilePath 'msiexec.exe'"));
|
||||
@@ -413,6 +419,29 @@ fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() {
|
||||
assert!(proxifyre_step < packet_filter_step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_uninstall_script_leaves_shared_packet_filter_installed() {
|
||||
let detected = DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre"),
|
||||
executable_path: PathBuf::from(
|
||||
r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe",
|
||||
),
|
||||
config_path: None,
|
||||
running: false,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("stopped".to_string()),
|
||||
};
|
||||
|
||||
let script = commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(false));
|
||||
|
||||
assert!(script.contains("$removePacketFilter = $false"));
|
||||
assert!(script.contains("if ($removePacketFilter)"));
|
||||
assert!(script.contains("Windows Packet Filter оставлен"));
|
||||
assert!(!script.contains("Get-Process -Name 'ProxiFyre'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singbox_runner_preserves_installer_args_with_spaces() {
|
||||
let script = commands::singbox_installer_runner_script(
|
||||
@@ -540,6 +569,20 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
assert!(proxyfier.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_status_does_not_keep_stale_installed_state_when_detection_is_missing() {
|
||||
let components = resolve_component_statuses(vec![proxyfier_running()], None, None);
|
||||
let proxyfier = components
|
||||
.iter()
|
||||
.find(|component| component.id == ComponentId::Proxyfier)
|
||||
.expect("proxyfier component");
|
||||
|
||||
assert_eq!(proxyfier.state, ComponentState::Missing);
|
||||
assert!(!proxyfier.installed);
|
||||
assert!(!proxyfier.running);
|
||||
assert_eq!(proxyfier.path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||
let root = test_root("detected-proxifyre");
|
||||
@@ -782,3 +825,10 @@ fn singbox_missing() -> ComponentStatus {
|
||||
actions: vec!["Установить локальный sing-box".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_ownership(remove_packet_filter: bool) -> ManagedProxiFyreOwnership {
|
||||
ManagedProxiFyreOwnership {
|
||||
service_name: "ProxiFyreService".to_string(),
|
||||
remove_packet_filter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ fn detects_existing_proxifyre_from_registry_install_location() {
|
||||
.with_registry("ProxiFyre", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_service("ProxiFyreService");
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
|
||||
let detected = detect_proxyfier_install_with_host(&host)
|
||||
.expect("existing ProxiFyre install should be detected");
|
||||
@@ -82,7 +85,10 @@ fn reports_stopped_proxifyre_service_when_executable_exists() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_stopped_service("ProxiFyreService");
|
||||
.with_stopped_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
|
||||
let detected =
|
||||
detect_proxyfier_install_with_host(&host).expect("proxifyre executable should be detected");
|
||||
@@ -105,6 +111,37 @@ fn missing_proxyfier_returns_install_action_status() {
|
||||
assert_eq!(component.actions, vec!["Установить ProxiFyre"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_known_service_name_when_path_points_to_foreign_binary() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Foreign\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
|
||||
let detected =
|
||||
detect_proxyfier_install_with_host(&host).expect("executable should still be detected");
|
||||
|
||||
assert!(!detected.running);
|
||||
assert_eq!(detected.service_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_known_service_name_without_path_metadata() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"C:\Tools\ProxiFyre")
|
||||
.with_path(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_service("ProxiFyreService");
|
||||
|
||||
let detected =
|
||||
detect_proxyfier_install_with_host(&host).expect("executable should still be detected");
|
||||
|
||||
assert!(!detected.running);
|
||||
assert_eq!(detected.service_status, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
let host = MockHost::new()
|
||||
@@ -176,6 +213,7 @@ struct MockHost {
|
||||
paths: HashSet<String>,
|
||||
processes: HashSet<String>,
|
||||
services: HashMap<String, String>,
|
||||
service_paths: HashMap<String, String>,
|
||||
registry: Vec<RegistryInstallEntry>,
|
||||
}
|
||||
|
||||
@@ -205,9 +243,19 @@ impl MockHost {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_stopped_service(mut self, service: &str) -> Self {
|
||||
fn with_service_path(mut self, service: &str, path_name: &str) -> Self {
|
||||
self.services
|
||||
.insert(service.to_ascii_lowercase(), "running".to_string());
|
||||
self.service_paths
|
||||
.insert(service.to_ascii_lowercase(), path_name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
fn with_stopped_service_path(mut self, service: &str, path_name: &str) -> Self {
|
||||
self.services
|
||||
.insert(service.to_ascii_lowercase(), "stopped".to_string());
|
||||
self.service_paths
|
||||
.insert(service.to_ascii_lowercase(), path_name.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
@@ -241,6 +289,20 @@ impl ProxyfierDetectionHost for MockHost {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn service_info(
|
||||
&self,
|
||||
service_name: &str,
|
||||
) -> Option<proxywarden_lib::component_detection::DetectedService> {
|
||||
let key = service_name.to_ascii_lowercase();
|
||||
self.services.get(&key).map(|status| {
|
||||
proxywarden_lib::component_detection::DetectedService {
|
||||
name: service_name.to_string(),
|
||||
status: status.clone(),
|
||||
path_name: self.service_paths.get(&key).cloned(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||
self.registry.clone()
|
||||
}
|
||||
|
||||
@@ -121,3 +121,91 @@ fn rejects_malformed_target_fields() {
|
||||
assert!(error.iter().any(|item| item.field == "protocol"));
|
||||
assert!(error.iter().any(|item| item.field == "requires_component"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_names_receive_distinct_stable_ids() {
|
||||
let profile = normalize_profile(ProfileInput {
|
||||
id: None,
|
||||
name: "Игры".to_string(),
|
||||
enabled: true,
|
||||
target_id: "main-proxy".to_string(),
|
||||
protocols: vec!["TCP".to_string()],
|
||||
items: vec![ProfileItemInput {
|
||||
item_type: "process".to_string(),
|
||||
value: "game.exe".to_string(),
|
||||
recursive: None,
|
||||
}],
|
||||
})
|
||||
.expect("unicode profile should normalize");
|
||||
let other = normalize_profile(ProfileInput {
|
||||
id: None,
|
||||
name: "Работа".to_string(),
|
||||
enabled: true,
|
||||
target_id: "main-proxy".to_string(),
|
||||
protocols: vec!["TCP".to_string()],
|
||||
items: vec![ProfileItemInput {
|
||||
item_type: "process".to_string(),
|
||||
value: "work.exe".to_string(),
|
||||
recursive: None,
|
||||
}],
|
||||
})
|
||||
.expect("second unicode profile should normalize");
|
||||
|
||||
assert!(profile.id.starts_with("profile-"));
|
||||
assert!(other.id.starts_with("profile-"));
|
||||
assert_ne!(profile.id, other.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_host_with_scheme_credentials_or_path() {
|
||||
for host in [
|
||||
"socks5://proxy.example.test",
|
||||
"user@proxy.example.test",
|
||||
"proxy.example.test/path",
|
||||
] {
|
||||
let error = normalize_target(TargetInput {
|
||||
id: None,
|
||||
name: "Invalid host".to_string(),
|
||||
kind: "external".to_string(),
|
||||
protocol: "socks5".to_string(),
|
||||
host: host.to_string(),
|
||||
port: 1080,
|
||||
requires_component: None,
|
||||
})
|
||||
.expect_err("host must not contain URL syntax");
|
||||
|
||||
assert!(error.iter().any(|item| item.field == "host"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_relative_or_non_executable_profile_paths() {
|
||||
let error = normalize_profile(ProfileInput {
|
||||
id: None,
|
||||
name: "Invalid paths".to_string(),
|
||||
enabled: true,
|
||||
target_id: "main-proxy".to_string(),
|
||||
protocols: vec!["TCP".to_string()],
|
||||
items: vec![
|
||||
ProfileItemInput {
|
||||
item_type: "folder".to_string(),
|
||||
value: r"relative\folder".to_string(),
|
||||
recursive: None,
|
||||
},
|
||||
ProfileItemInput {
|
||||
item_type: "exe".to_string(),
|
||||
value: r"C:\Games\game.txt".to_string(),
|
||||
recursive: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
.expect_err("unsafe path shapes should fail validation");
|
||||
|
||||
assert_eq!(
|
||||
error
|
||||
.iter()
|
||||
.filter(|item| item.field == "items.value")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +83,35 @@ fn includes_folder_paths_when_generating_proxifyre_config() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deduplicates_windows_app_names_case_insensitively() {
|
||||
let adapter = ProxiFyreAdapter::default();
|
||||
let mut profile = discord_profile("home-gateway");
|
||||
profile.items.extend([
|
||||
ProfileItem {
|
||||
item_type: ProfileItemType::Process,
|
||||
value: "discord".to_string(),
|
||||
recursive: false,
|
||||
},
|
||||
ProfileItem {
|
||||
item_type: ProfileItemType::Exe,
|
||||
value: "DISCORD".to_string(),
|
||||
recursive: false,
|
||||
},
|
||||
]);
|
||||
let profiles = vec![profile];
|
||||
let targets = vec![external_socks5_target()];
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &[]))
|
||||
.expect("Windows app names should generate");
|
||||
let config: ProxiFyreConfig =
|
||||
serde_json::from_str(&generated.contents).expect("generated config json");
|
||||
|
||||
assert_eq!(config.proxies[0].app_names, vec!["Discord"]);
|
||||
assert_eq!(generated.routed_apps, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_local_singbox_target_when_required_component_is_missing() {
|
||||
let adapter = ProxiFyreAdapter::default();
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
use proxywarden_lib::proxifyre_ownership::verify_managed_proxifyre_install;
|
||||
use serde_json::json;
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
#[test]
|
||||
fn accepts_matching_managed_install_and_returns_packet_filter_ownership() {
|
||||
let fixture = ManagedInstallFixture::new("owned");
|
||||
fixture.write_marker(true, &fixture.install_dir);
|
||||
|
||||
let ownership = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&fixture.install_dir,
|
||||
)
|
||||
.expect("matching marker should prove ownership");
|
||||
|
||||
assert_eq!(ownership.service_name, "ProxiFyreService");
|
||||
assert!(ownership.remove_packet_filter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_install_outside_expected_managed_directory() {
|
||||
let fixture = ManagedInstallFixture::new("unexpected-root");
|
||||
fixture.write_marker(true, &fixture.install_dir);
|
||||
let other_root = fixture
|
||||
.root
|
||||
.join("other")
|
||||
.join("components")
|
||||
.join("ProxiFyre");
|
||||
fs::create_dir_all(&other_root).expect("other root should be created");
|
||||
|
||||
let error = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&other_root,
|
||||
)
|
||||
.expect_err("a detected portable install must not be recursively removed");
|
||||
|
||||
assert!(error.contains("не является управляемой папкой"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_marker_with_mismatched_install_root() {
|
||||
let fixture = ManagedInstallFixture::new("mismatched-marker");
|
||||
fixture.write_marker(false, &fixture.root);
|
||||
|
||||
let error = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&fixture.install_dir,
|
||||
)
|
||||
.expect_err("marker installRoot must match the managed directory");
|
||||
|
||||
assert!(error.contains("installRoot из marker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_marker_with_foreign_service_name() {
|
||||
let fixture = ManagedInstallFixture::new("foreign-service");
|
||||
fixture.write_custom_marker(json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ForeignProxyService",
|
||||
"installRoot": fixture.install_dir,
|
||||
"packetFilterInstalledByProxyWarden": true
|
||||
}));
|
||||
|
||||
let error = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&fixture.install_dir,
|
||||
)
|
||||
.expect_err("foreign service name must not be trusted");
|
||||
|
||||
assert!(error.contains("неподдерживаемое имя службы"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_packet_filter_flag_defaults_to_not_owned() {
|
||||
let fixture = ManagedInstallFixture::new("shared-driver");
|
||||
fixture.write_custom_marker(json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": fixture.install_dir
|
||||
}));
|
||||
|
||||
let ownership = verify_managed_proxifyre_install(
|
||||
&fixture.install_dir,
|
||||
&fixture.executable_path,
|
||||
&fixture.install_dir,
|
||||
)
|
||||
.expect("valid marker without ownership flag should remain safe");
|
||||
|
||||
assert!(!ownership.remove_packet_filter);
|
||||
}
|
||||
|
||||
struct ManagedInstallFixture {
|
||||
root: PathBuf,
|
||||
install_dir: PathBuf,
|
||||
executable_path: PathBuf,
|
||||
}
|
||||
|
||||
impl ManagedInstallFixture {
|
||||
fn new(label: &str) -> Self {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"proxywarden-ownership-{label}-{}",
|
||||
uuid::Uuid::new_v4().hyphenated()
|
||||
));
|
||||
let install_dir = root.join("components").join("ProxiFyre");
|
||||
let executable_path = install_dir.join("ProxiFyre.exe");
|
||||
fs::create_dir_all(&install_dir).expect("managed install directory should be created");
|
||||
fs::write(&executable_path, b"fixture").expect("fixture executable should be written");
|
||||
|
||||
Self {
|
||||
root,
|
||||
install_dir,
|
||||
executable_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_marker(&self, packet_filter_owned: bool, install_root: &std::path::Path) {
|
||||
self.write_custom_marker(json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": install_root,
|
||||
"packetFilterInstalledByProxyWarden": packet_filter_owned
|
||||
}));
|
||||
}
|
||||
|
||||
fn write_custom_marker(&self, marker: serde_json::Value) {
|
||||
fs::write(
|
||||
self.install_dir.join("proxywarden-component.json"),
|
||||
serde_json::to_vec_pretty(&marker).expect("marker should serialize"),
|
||||
)
|
||||
.expect("marker should be written");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ManagedInstallFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ fn blocks_config_when_server_is_not_selected() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let mut config = local_singbox_config("nl-1");
|
||||
config.selected_server_tag = None;
|
||||
config.selected_server_id = None;
|
||||
let cache = subscription_cache();
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
@@ -108,8 +109,9 @@ fn blocks_config_when_server_is_not_selected() {
|
||||
#[test]
|
||||
fn blocks_config_when_selected_outbound_is_missing() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let config = local_singbox_config("missing-server");
|
||||
let cache = subscription_cache();
|
||||
let config = local_singbox_config("nl-1");
|
||||
let mut cache = subscription_cache();
|
||||
cache.config = serde_json::json!({ "outbounds": [] });
|
||||
let checker = RecordingChecker::ok("should not run");
|
||||
|
||||
let error = adapter
|
||||
@@ -120,10 +122,67 @@ fn blocks_config_when_selected_outbound_is_missing() {
|
||||
.expect_err("missing outbound should block config");
|
||||
|
||||
assert_eq!(error.kind, SingBoxConfigErrorKind::MissingSelectedOutbound);
|
||||
assert!(error.message.contains("missing-server"));
|
||||
assert!(error.message.contains("nl-1"));
|
||||
assert!(checker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tags_generate_the_outbound_selected_by_stable_id() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let mut config = local_singbox_config("shared-name");
|
||||
config.selected_server_id = Some("vless|shared-name|second.example.test|8443".to_string());
|
||||
let cache = SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "shared-name",
|
||||
"server": "first.example.test",
|
||||
"server_port": 443,
|
||||
"uuid": "11111111-1111-1111-1111-111111111111"
|
||||
},
|
||||
{
|
||||
"type": "vless",
|
||||
"tag": "shared-name",
|
||||
"server": "second.example.test",
|
||||
"server_port": 8443,
|
||||
"uuid": "22222222-2222-2222-2222-222222222222"
|
||||
}
|
||||
]
|
||||
}),
|
||||
servers: vec![
|
||||
SubscriptionServer {
|
||||
id: "vless|shared-name|first.example.test|443".to_string(),
|
||||
tag: "shared-name".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "first.example.test".to_string(),
|
||||
server_port: 443,
|
||||
},
|
||||
SubscriptionServer {
|
||||
id: "vless|shared-name|second.example.test|8443".to_string(),
|
||||
tag: "shared-name".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "second.example.test".to_string(),
|
||||
server_port: 8443,
|
||||
},
|
||||
],
|
||||
user_info: serde_json::Map::new(),
|
||||
fetched_at: "2026-07-11T00:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&RecordingChecker::ok("not used"),
|
||||
)
|
||||
.expect("stable id should resolve the second duplicate tag");
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&generated.contents).expect("generated config should parse");
|
||||
|
||||
assert_eq!(value["outbounds"][0]["server"], "second.example.test");
|
||||
assert_eq!(value["outbounds"][0]["server_port"], 8443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_failed_singbox_check_as_structured_error() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
@@ -208,6 +267,7 @@ fn local_singbox_config(selected_server_tag: &str) -> LocalSingBoxConfig {
|
||||
subscription_url: Some("https://sub.example.test/list".to_string()),
|
||||
device_hwid: None,
|
||||
selected_server_tag: Some(selected_server_tag.to_string()),
|
||||
selected_server_id: Some(format!("vless|{selected_server_tag}|nl.example.test|443")),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
@@ -234,6 +294,7 @@ fn subscription_cache() -> SubscriptionCache {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "vless|nl-1|nl.example.test|443".to_string(),
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
|
||||
@@ -208,6 +208,7 @@ fn selects_server_from_cached_subscription() {
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
id: Some("trojan|de-1|de.example.test|443".to_string()),
|
||||
tag: "de-1".to_string(),
|
||||
server: None,
|
||||
server_port: None,
|
||||
@@ -220,7 +221,47 @@ fn selects_server_from_cached_subscription() {
|
||||
.expect("read local sing-box config");
|
||||
|
||||
assert_eq!(status.config.selected_server_tag, Some("de-1".to_string()));
|
||||
assert_eq!(
|
||||
status.config.selected_server_id,
|
||||
Some("trojan|de-1|de.example.test|443".to_string())
|
||||
);
|
||||
assert_eq!(config.selected_server_tag, Some("de-1".to_string()));
|
||||
assert_eq!(
|
||||
config.selected_server_id,
|
||||
Some("trojan|de-1|de.example.test|443".to_string())
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_duplicate_tag_by_stable_server_id() {
|
||||
let root = test_root("select-duplicate-tag");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let mut cache = sample_cache();
|
||||
cache.servers[1].tag = "nl-1".to_string();
|
||||
cache.servers[1].id = "trojan|nl-1|de.example.test|443".to_string();
|
||||
storage
|
||||
.write_singbox_subscription_cache(&cache)
|
||||
.expect("write cache");
|
||||
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
id: Some("trojan|nl-1|de.example.test|443".to_string()),
|
||||
tag: "nl-1".to_string(),
|
||||
server: Some("de.example.test".to_string()),
|
||||
server_port: Some(443),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.expect("stable id should select the second duplicate tag");
|
||||
|
||||
assert_eq!(
|
||||
status.config.selected_server_id,
|
||||
Some("trojan|nl-1|de.example.test|443".to_string())
|
||||
);
|
||||
assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string()));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -236,6 +277,7 @@ fn selects_server_by_endpoint_when_display_tag_is_sanitized() {
|
||||
let status = select_singbox_server_in_storage(
|
||||
&storage,
|
||||
SelectSingBoxServerInputDto {
|
||||
id: None,
|
||||
tag: "Умный".to_string(),
|
||||
server: Some("media.example.test".to_string()),
|
||||
server_port: Some(443),
|
||||
@@ -465,12 +507,14 @@ fn sample_cache() -> SubscriptionCache {
|
||||
}),
|
||||
servers: vec![
|
||||
SubscriptionServer {
|
||||
id: "vless|nl-1|nl.example.test|443".to_string(),
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
server_port: 443,
|
||||
},
|
||||
SubscriptionServer {
|
||||
id: "trojan|de-1|de.example.test|443".to_string(),
|
||||
tag: "de-1".to_string(),
|
||||
server_type: "trojan".to_string(),
|
||||
server: "de.example.test".to_string(),
|
||||
@@ -496,6 +540,7 @@ fn sample_cache_with_flag_tag() -> SubscriptionCache {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "vless|Умный 🇳🇱->🇷🇺|media.example.test|443".to_string(),
|
||||
tag: "Умный 🇳🇱->🇷🇺".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "media.example.test".to_string(),
|
||||
|
||||
@@ -54,6 +54,7 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
subscription_url: Some("https://sub.example.test/path?token=secret".to_string()),
|
||||
device_hwid: Some("hwid-abcdef1234".to_string()),
|
||||
selected_server_tag: Some("nl-1".to_string()),
|
||||
selected_server_id: Some("vless|nl-1|nl.example.test|443".to_string()),
|
||||
listen_host: "127.0.0.1".to_string(),
|
||||
listen_port: 1080,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
@@ -133,6 +134,7 @@ fn reads_percent_encoded_singbox_tags_as_utf8() {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: String::new(),
|
||||
tag: encoded_tag.to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
@@ -390,6 +392,7 @@ fn sample_subscription_cache() -> SubscriptionCache {
|
||||
]
|
||||
}),
|
||||
servers: vec![SubscriptionServer {
|
||||
id: "vless|nl-1|nl.example.test|443".to_string(),
|
||||
tag: "nl-1".to_string(),
|
||||
server_type: "vless".to_string(),
|
||||
server: "nl.example.test".to_string(),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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,
|
||||
self, parse_subscription_body, parse_user_info, validate_resolved_subscription_addresses,
|
||||
SubscriptionFetchIdentity, SubscriptionFetchPolicy,
|
||||
};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
@@ -27,6 +27,29 @@ fn parses_singbox_json_config_servers() {
|
||||
assert_eq!(parsed.servers[1].server_port, 8443);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_ids_are_opaque_and_distinguish_credentials_on_same_endpoint() {
|
||||
let parsed = parse_subscription_body(
|
||||
r#"{
|
||||
"outbounds": [
|
||||
{ "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "11111111-1111-1111-1111-111111111111" },
|
||||
{ "type": "vless", "tag": "same", "server": "edge.example.test", "server_port": 443, "uuid": "22222222-2222-2222-2222-222222222222" }
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("duplicate endpoint subscription should parse");
|
||||
|
||||
assert_ne!(parsed.servers[0].id, parsed.servers[1].id);
|
||||
assert!(parsed
|
||||
.servers
|
||||
.iter()
|
||||
.all(|server| server.id.starts_with("pw-")));
|
||||
assert!(parsed
|
||||
.servers
|
||||
.iter()
|
||||
.all(|server| !server.id.contains("11111111")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_base64_vless_link_list() {
|
||||
let link = sample_vless_link("nl-1");
|
||||
@@ -42,6 +65,45 @@ fn parses_base64_vless_link_list() {
|
||||
assert_eq!(outbound["packet_encoding"], "xudp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_trojan_shadowsocks_and_vmess_link_formats() {
|
||||
let vmess_payload = serde_json::json!({
|
||||
"v": "2",
|
||||
"ps": "VMess NL",
|
||||
"add": "vmess.example.test",
|
||||
"port": "443",
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"scy": "auto",
|
||||
"net": "ws",
|
||||
"host": "cdn.example.test",
|
||||
"path": "/ws",
|
||||
"tls": "tls",
|
||||
"sni": "vmess.example.test"
|
||||
});
|
||||
let vmess_link = format!(
|
||||
"vmess://{}",
|
||||
general_purpose::STANDARD_NO_PAD.encode(vmess_payload.to_string())
|
||||
);
|
||||
let body = format!(
|
||||
"trojan://secret@trojan.example.test:443?sni=edge.example.test#Trojan%20DE\nss://aes-256-gcm:password@ss.example.test:8388#SS%20US\n{vmess_link}"
|
||||
);
|
||||
|
||||
let parsed = parse_subscription_body(&body).expect("supported link formats should parse");
|
||||
|
||||
assert_eq!(parsed.servers.len(), 3);
|
||||
assert_eq!(parsed.servers[0].server_type, "trojan");
|
||||
assert_eq!(parsed.servers[0].tag, "Trojan DE");
|
||||
assert_eq!(
|
||||
parsed.config["outbounds"][0]["tls"]["server_name"],
|
||||
"edge.example.test"
|
||||
);
|
||||
assert_eq!(parsed.servers[1].server_type, "shadowsocks");
|
||||
assert_eq!(parsed.config["outbounds"][1]["method"], "aes-256-gcm");
|
||||
assert_eq!(parsed.servers[2].server_type, "vmess");
|
||||
assert_eq!(parsed.config["outbounds"][2]["transport"]["type"], "ws");
|
||||
assert_eq!(parsed.config["outbounds"][2]["tls"]["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_percent_encoded_vless_fragment_tag() {
|
||||
let link = sample_vless_link(
|
||||
@@ -111,6 +173,25 @@ fn rejects_unsafe_local_subscription_urls_before_network() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dns_results_containing_private_or_metadata_addresses() {
|
||||
for ip in [
|
||||
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
|
||||
IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)),
|
||||
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
|
||||
] {
|
||||
let error = validate_resolved_subscription_addresses(&[SocketAddr::new(ip, 443)])
|
||||
.expect_err("unsafe resolved address should be blocked");
|
||||
assert!(error.message.contains("resolves to"));
|
||||
}
|
||||
|
||||
validate_resolved_subscription_addresses(&[SocketAddr::new(
|
||||
IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
|
||||
443,
|
||||
)])
|
||||
.expect("public resolved address should be accepted");
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
Reference in New Issue
Block a user