503 lines
16 KiB
Rust
503 lines
16 KiB
Rust
#[path = "../src/activity.rs"]
|
|
mod activity;
|
|
#[path = "../src/commands.rs"]
|
|
mod commands;
|
|
#[path = "../src/component_detection.rs"]
|
|
mod component_detection;
|
|
#[path = "../src/models.rs"]
|
|
mod models;
|
|
#[path = "../src/process.rs"]
|
|
mod process;
|
|
#[path = "../src/adapters/proxifyre.rs"]
|
|
mod proxifyre;
|
|
#[path = "../src/adapters/proxy_router.rs"]
|
|
mod proxy_router;
|
|
#[path = "../src/adapters/singbox.rs"]
|
|
mod singbox;
|
|
#[path = "../src/singbox_service.rs"]
|
|
mod singbox_service;
|
|
#[path = "../src/storage.rs"]
|
|
mod storage;
|
|
#[path = "../src/subscription.rs"]
|
|
mod subscription;
|
|
#[path = "../src/validation.rs"]
|
|
mod validation;
|
|
|
|
use commands::{
|
|
fetch_singbox_subscription_with_fetcher, forget_singbox_subscription_in_storage,
|
|
generate_singbox_config_with_services, save_singbox_device_hwid_to_storage,
|
|
save_singbox_subscription_to_storage, select_singbox_server_in_storage, Clock,
|
|
SaveSingBoxDeviceHwidInputDto, SaveSingBoxSubscriptionInputDto, SelectSingBoxServerInputDto,
|
|
SubscriptionFetcher,
|
|
};
|
|
use models::{
|
|
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
|
|
SubscriptionServer, TargetKind,
|
|
};
|
|
use serde_json::{json, Map};
|
|
use singbox::{SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError};
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
use storage::JsonStorage;
|
|
|
|
#[test]
|
|
fn saves_subscription_url_without_exposing_secret_query() {
|
|
let root = test_root("save-subscription");
|
|
let storage = JsonStorage::new(root.clone());
|
|
|
|
let status = save_singbox_subscription_to_storage(
|
|
&storage,
|
|
SaveSingBoxSubscriptionInputDto {
|
|
subscription_url: " https://sub.example.test/path?token=secret ".to_string(),
|
|
device_hwid: Some(" hwid-1234567890 ".to_string()),
|
|
},
|
|
&FixedClock,
|
|
)
|
|
.expect("subscription URL should be saved");
|
|
let config = storage
|
|
.read_local_singbox_config()
|
|
.expect("read local sing-box config");
|
|
|
|
assert_eq!(
|
|
config.subscription_url,
|
|
Some("https://sub.example.test/path?token=secret".to_string())
|
|
);
|
|
assert_eq!(config.device_hwid, Some("hwid-1234567890".to_string()));
|
|
assert!(status.config.has_subscription);
|
|
assert!(status.config.has_device_hwid);
|
|
assert_eq!(
|
|
status.config.subscription_display_url,
|
|
Some("https://sub.example.test/...".to_string())
|
|
);
|
|
assert_eq!(
|
|
status.config.device_hwid_display,
|
|
Some("hwid...7890".to_string())
|
|
);
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn saves_and_clears_device_hwid_without_exposing_raw_value() {
|
|
let root = test_root("save-hwid");
|
|
let storage = JsonStorage::new(root.clone());
|
|
|
|
let status = save_singbox_device_hwid_to_storage(
|
|
&storage,
|
|
SaveSingBoxDeviceHwidInputDto {
|
|
device_hwid: Some(" device-abcdef1234 ".to_string()),
|
|
},
|
|
&FixedClock,
|
|
)
|
|
.expect("device hwid should be saved");
|
|
let config = storage
|
|
.read_local_singbox_config()
|
|
.expect("read local sing-box config");
|
|
|
|
assert_eq!(config.device_hwid, Some("device-abcdef1234".to_string()));
|
|
assert!(status.config.has_device_hwid);
|
|
assert_eq!(
|
|
status.config.device_hwid_display,
|
|
Some("devi...1234".to_string())
|
|
);
|
|
|
|
let status = save_singbox_device_hwid_to_storage(
|
|
&storage,
|
|
SaveSingBoxDeviceHwidInputDto { device_hwid: None },
|
|
&FixedClock,
|
|
)
|
|
.expect("device hwid should be cleared");
|
|
let config = storage
|
|
.read_local_singbox_config()
|
|
.expect("read local sing-box config");
|
|
|
|
assert_eq!(config.device_hwid, None);
|
|
assert!(!status.config.has_device_hwid);
|
|
assert_eq!(status.config.device_hwid_display, None);
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_http_subscription_url() {
|
|
let root = test_root("invalid-subscription");
|
|
let storage = JsonStorage::new(root.clone());
|
|
|
|
let error = save_singbox_subscription_to_storage(
|
|
&storage,
|
|
SaveSingBoxSubscriptionInputDto {
|
|
subscription_url: "file:///C:/sub.txt".to_string(),
|
|
device_hwid: None,
|
|
},
|
|
&FixedClock,
|
|
)
|
|
.expect_err("non-http subscription URL should fail");
|
|
|
|
assert_eq!(error.code, "singbox_subscription_url_invalid");
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn fetches_subscription_cache_and_selects_first_server() {
|
|
let root = test_root("fetch-subscription");
|
|
let storage = JsonStorage::new(root.clone());
|
|
save_singbox_subscription_to_storage(
|
|
&storage,
|
|
SaveSingBoxSubscriptionInputDto {
|
|
subscription_url: "https://sub.example.test/path?token=secret".to_string(),
|
|
device_hwid: None,
|
|
},
|
|
&FixedClock,
|
|
)
|
|
.expect("save subscription URL");
|
|
|
|
let status = fetch_singbox_subscription_with_fetcher(
|
|
&storage,
|
|
&MockFetcher(sample_cache()),
|
|
&FixedClock,
|
|
)
|
|
.expect("fetch subscription through mock");
|
|
let config = storage
|
|
.read_local_singbox_config()
|
|
.expect("read local sing-box config");
|
|
let cache = storage
|
|
.read_singbox_subscription_cache()
|
|
.expect("read cache")
|
|
.expect("cache should exist");
|
|
let activity = storage.read_activity().expect("read activity");
|
|
|
|
assert_eq!(status.config.selected_server_tag, Some("nl-1".to_string()));
|
|
assert_eq!(status.cache.expect("status cache").servers.len(), 2);
|
|
assert_eq!(config.selected_server_tag, Some("nl-1".to_string()));
|
|
assert_eq!(cache.servers.len(), 2);
|
|
assert_eq!(activity[0].level, ActivityLevel::Success);
|
|
assert_eq!(activity[0].title, "Подписка Local sing-box обновлена");
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn fetches_subscription_with_saved_device_hwid() {
|
|
let root = test_root("fetch-subscription-hwid");
|
|
let storage = JsonStorage::new(root.clone());
|
|
save_singbox_subscription_to_storage(
|
|
&storage,
|
|
SaveSingBoxSubscriptionInputDto {
|
|
subscription_url: "https://sub.example.test/path?token=secret".to_string(),
|
|
device_hwid: Some("hwid-abcdef1234".to_string()),
|
|
},
|
|
&FixedClock,
|
|
)
|
|
.expect("save subscription URL and HWID");
|
|
|
|
fetch_singbox_subscription_with_fetcher(
|
|
&storage,
|
|
&HwidAssertingFetcher {
|
|
cache: sample_cache(),
|
|
expected_hwid: Some("hwid-abcdef1234"),
|
|
},
|
|
&FixedClock,
|
|
)
|
|
.expect("fetch subscription through mock");
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn selects_server_from_cached_subscription() {
|
|
let root = test_root("select-server");
|
|
let storage = JsonStorage::new(root.clone());
|
|
storage
|
|
.write_singbox_subscription_cache(&sample_cache())
|
|
.expect("write cache");
|
|
|
|
let status = select_singbox_server_in_storage(
|
|
&storage,
|
|
SelectSingBoxServerInputDto {
|
|
tag: "de-1".to_string(),
|
|
server: None,
|
|
server_port: None,
|
|
},
|
|
&FixedClock,
|
|
)
|
|
.expect("server should be selected");
|
|
let config = storage
|
|
.read_local_singbox_config()
|
|
.expect("read local sing-box config");
|
|
|
|
assert_eq!(status.config.selected_server_tag, Some("de-1".to_string()));
|
|
assert_eq!(config.selected_server_tag, Some("de-1".to_string()));
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn selects_server_by_endpoint_when_display_tag_is_sanitized() {
|
|
let root = test_root("select-server-sanitized-tag");
|
|
let storage = JsonStorage::new(root.clone());
|
|
storage
|
|
.write_singbox_subscription_cache(&sample_cache_with_flag_tag())
|
|
.expect("write cache");
|
|
|
|
let status = select_singbox_server_in_storage(
|
|
&storage,
|
|
SelectSingBoxServerInputDto {
|
|
tag: "Умный".to_string(),
|
|
server: Some("media.example.test".to_string()),
|
|
server_port: Some(443),
|
|
},
|
|
&FixedClock,
|
|
)
|
|
.expect("server should be selected by endpoint fallback");
|
|
let config = storage
|
|
.read_local_singbox_config()
|
|
.expect("read local sing-box config");
|
|
|
|
assert_eq!(
|
|
status.config.selected_server_tag,
|
|
Some("Умный 🇳🇱->🇷🇺".to_string())
|
|
);
|
|
assert_eq!(config.selected_server_tag, Some("Умный 🇳🇱->🇷🇺".to_string()));
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn generate_writes_config_and_local_singbox_target() {
|
|
let root = test_root("generate-config");
|
|
let storage = JsonStorage::new(root.clone());
|
|
storage
|
|
.write_local_singbox_config(&LocalSingBoxConfig {
|
|
subscription_url: Some("https://sub.example.test/path".to_string()),
|
|
device_hwid: Some("hwid-abcdef1234".to_string()),
|
|
selected_server_tag: Some("nl-1".to_string()),
|
|
..LocalSingBoxConfig::default()
|
|
})
|
|
.expect("write local sing-box config");
|
|
storage
|
|
.write_singbox_subscription_cache(&sample_cache())
|
|
.expect("write cache");
|
|
|
|
let response = generate_singbox_config_with_services(
|
|
&storage,
|
|
&SingBoxAdapter::default(),
|
|
&MockChecker,
|
|
&FixedClock,
|
|
Some(Path::new("sing-box.exe")),
|
|
)
|
|
.expect("generate sing-box config");
|
|
let generated = fs::read_to_string(&response.generated_config_path).expect("read generated");
|
|
let targets = storage.read_targets().expect("read targets");
|
|
let target = targets
|
|
.iter()
|
|
.find(|target| target.id == "local-singbox")
|
|
.expect("local sing-box target");
|
|
let activity = storage.read_activity().expect("read activity");
|
|
|
|
assert!(response.success);
|
|
assert_eq!(response.adapter_id, "singbox");
|
|
assert_eq!(response.selected_server_tag, "nl-1");
|
|
assert!(response.check.expect("check result").success);
|
|
assert!(generated.contains("\"type\": \"mixed\""));
|
|
assert!(generated.contains("\"tag\": \"vpn\""));
|
|
assert_eq!(target.kind, TargetKind::Local);
|
|
assert_eq!(target.protocol, ProxyProtocol::Socks5);
|
|
assert_eq!(target.host, "127.0.0.1");
|
|
assert_eq!(target.port, 1080);
|
|
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
|
|
assert_eq!(activity[0].title, "Конфиг Local sing-box создан");
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn generate_requires_cached_subscription() {
|
|
let root = test_root("generate-missing-cache");
|
|
let storage = JsonStorage::new(root.clone());
|
|
|
|
let error = generate_singbox_config_with_services(
|
|
&storage,
|
|
&SingBoxAdapter::default(),
|
|
&MockChecker,
|
|
&FixedClock,
|
|
None,
|
|
)
|
|
.expect_err("missing cache should block generation");
|
|
|
|
assert_eq!(error.code, "singbox_subscription_cache_missing");
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
#[test]
|
|
fn forget_subscription_clears_url_selection_and_cache() {
|
|
let root = test_root("forget-subscription");
|
|
let storage = JsonStorage::new(root.clone());
|
|
storage
|
|
.write_local_singbox_config(&LocalSingBoxConfig {
|
|
subscription_url: Some("https://sub.example.test/path".to_string()),
|
|
device_hwid: Some("hwid-abcdef1234".to_string()),
|
|
selected_server_tag: Some("nl-1".to_string()),
|
|
..LocalSingBoxConfig::default()
|
|
})
|
|
.expect("write local sing-box config");
|
|
storage
|
|
.write_singbox_subscription_cache(&sample_cache())
|
|
.expect("write cache");
|
|
|
|
let status =
|
|
forget_singbox_subscription_in_storage(&storage, &FixedClock).expect("forget subscription");
|
|
let config = storage
|
|
.read_local_singbox_config()
|
|
.expect("read local sing-box config");
|
|
let cache = storage
|
|
.read_singbox_subscription_cache()
|
|
.expect("read cache");
|
|
|
|
assert!(!status.config.has_subscription);
|
|
assert_eq!(config.subscription_url, None);
|
|
assert_eq!(config.device_hwid, None);
|
|
assert_eq!(config.selected_server_tag, None);
|
|
assert_eq!(cache, None);
|
|
|
|
cleanup(&root);
|
|
}
|
|
|
|
struct MockFetcher(SubscriptionCache);
|
|
|
|
impl SubscriptionFetcher for MockFetcher {
|
|
fn fetch_subscription(
|
|
&self,
|
|
url: &str,
|
|
_identity: &subscription::SubscriptionFetchIdentity,
|
|
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
|
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
|
Ok(self.0.clone())
|
|
}
|
|
}
|
|
|
|
struct HwidAssertingFetcher {
|
|
cache: SubscriptionCache,
|
|
expected_hwid: Option<&'static str>,
|
|
}
|
|
|
|
impl SubscriptionFetcher for HwidAssertingFetcher {
|
|
fn fetch_subscription(
|
|
&self,
|
|
url: &str,
|
|
identity: &subscription::SubscriptionFetchIdentity,
|
|
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
|
assert_eq!(url, "https://sub.example.test/path?token=secret");
|
|
assert_eq!(identity.device_hwid.as_deref(), self.expected_hwid);
|
|
assert_eq!(identity.app_name, "ProxyWarden");
|
|
assert!(identity.user_agent.starts_with("ProxyWarden/"));
|
|
assert_eq!(identity.device_os, std::env::consts::OS);
|
|
assert_eq!(identity.device_model, "ProxyWarden");
|
|
Ok(self.cache.clone())
|
|
}
|
|
}
|
|
|
|
struct MockChecker;
|
|
|
|
impl SingBoxConfigChecker for MockChecker {
|
|
fn check_config(
|
|
&self,
|
|
binary_path: &Path,
|
|
config_json: &str,
|
|
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
|
assert_eq!(binary_path, Path::new("sing-box.exe"));
|
|
assert!(config_json.contains("\"final\": \"vpn\""));
|
|
Ok(SingBoxCheckResult {
|
|
checked: true,
|
|
success: true,
|
|
message: "mock check passed".to_string(),
|
|
})
|
|
}
|
|
}
|
|
|
|
struct FixedClock;
|
|
|
|
impl Clock for FixedClock {
|
|
fn now(&self) -> String {
|
|
"2026-07-07T00:00:00Z".to_string()
|
|
}
|
|
}
|
|
|
|
fn sample_cache() -> SubscriptionCache {
|
|
SubscriptionCache {
|
|
config: json!({
|
|
"outbounds": [
|
|
{
|
|
"type": "vless",
|
|
"tag": "nl-1",
|
|
"server": "nl.example.test",
|
|
"server_port": 443,
|
|
"uuid": "11111111-1111-1111-1111-111111111111"
|
|
},
|
|
{
|
|
"type": "trojan",
|
|
"tag": "de-1",
|
|
"server": "de.example.test",
|
|
"server_port": 443,
|
|
"password": "secret"
|
|
}
|
|
]
|
|
}),
|
|
servers: vec![
|
|
SubscriptionServer {
|
|
tag: "nl-1".to_string(),
|
|
server_type: "vless".to_string(),
|
|
server: "nl.example.test".to_string(),
|
|
server_port: 443,
|
|
},
|
|
SubscriptionServer {
|
|
tag: "de-1".to_string(),
|
|
server_type: "trojan".to_string(),
|
|
server: "de.example.test".to_string(),
|
|
server_port: 443,
|
|
},
|
|
],
|
|
user_info: Map::new(),
|
|
fetched_at: "2026-07-07T00:00:00Z".to_string(),
|
|
}
|
|
}
|
|
|
|
fn sample_cache_with_flag_tag() -> SubscriptionCache {
|
|
SubscriptionCache {
|
|
config: json!({
|
|
"outbounds": [
|
|
{
|
|
"type": "vless",
|
|
"tag": "Умный 🇳🇱->🇷🇺",
|
|
"server": "media.example.test",
|
|
"server_port": 443,
|
|
"uuid": "11111111-1111-1111-1111-111111111111"
|
|
}
|
|
]
|
|
}),
|
|
servers: vec![SubscriptionServer {
|
|
tag: "Умный 🇳🇱->🇷🇺".to_string(),
|
|
server_type: "vless".to_string(),
|
|
server: "media.example.test".to_string(),
|
|
server_port: 443,
|
|
}],
|
|
user_info: Map::new(),
|
|
fetched_at: "2026-07-07T00:00:00Z".to_string(),
|
|
}
|
|
}
|
|
|
|
fn test_root(name: &str) -> PathBuf {
|
|
let timestamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system clock before unix epoch")
|
|
.as_nanos();
|
|
|
|
std::env::temp_dir().join(format!("proxywarden-singbox-commands-{name}-{timestamp}"))
|
|
}
|
|
|
|
fn cleanup(root: &Path) {
|
|
let _ = fs::remove_dir_all(root);
|
|
}
|