@@ -14,6 +14,8 @@ use proxywarden_lib::models::{
|
||||
Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetInput,
|
||||
TargetKind,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use proxywarden_lib::safe_fs;
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use std::{cell::Cell, fs, path::Path};
|
||||
|
||||
@@ -44,6 +46,9 @@ fn external_apply_commits_one_source_state_without_service_control() {
|
||||
target.id == "main-proxy" && target.host == "proxy.example.test" && target.port == 1080
|
||||
}));
|
||||
assert!(Path::new(&result.generated_config_path).exists());
|
||||
#[cfg(windows)]
|
||||
safe_fs::verify_path_protected_for_owner_admin_system(Path::new(&result.generated_config_path))
|
||||
.expect("generated config keeps restricted ACL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -169,6 +174,15 @@ fn helper_failure_rolls_back_source_and_generated_artifact() {
|
||||
fs::read(&generated_path).expect("generated after"),
|
||||
b"old-generated"
|
||||
);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
safe_fs::verify_path_protected_for_owner_admin_system(&generated_path)
|
||||
.expect("rollback keeps generated config restricted");
|
||||
assert!(
|
||||
!safe_fs::backup_path(&generated_path).exists(),
|
||||
"rollback restores prior absence of backup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -213,6 +227,7 @@ fn local_apply_with_missing_running_service_stops_at_preflight() {
|
||||
let error = run_apply(
|
||||
&fixture.storage,
|
||||
ApplyConfigurationInput {
|
||||
expected_revision: None,
|
||||
route_mode: ApplyRouteMode::LocalSingbox,
|
||||
profile: profile_input(),
|
||||
external_target: None,
|
||||
@@ -230,8 +245,61 @@ fn local_apply_with_missing_running_service_stops_at_preflight() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_shared_target_preserves_the_other_profile_and_target() {
|
||||
let fixture = ApplyFixture::new("shared-target");
|
||||
fixture.seed_old_state();
|
||||
let before_profiles = fixture.storage.read_profiles().unwrap();
|
||||
let before_targets = fixture.storage.read_targets().unwrap();
|
||||
let mut input = external_input();
|
||||
input.disable_other_profiles = false;
|
||||
input.external_target.as_mut().unwrap().id = Some("legacy-target".into());
|
||||
input.profile.protocols = vec!["TCP".into()];
|
||||
input.profile.items = vec![ProfileItemInput {
|
||||
item_type: "folder".into(),
|
||||
value: r"C:\Games".into(),
|
||||
recursive: Some(false),
|
||||
}];
|
||||
run_apply(&fixture.storage, input, &RecordingHelper::success()).unwrap();
|
||||
let profiles = fixture.storage.read_profiles().unwrap();
|
||||
let targets = fixture.storage.read_targets().unwrap();
|
||||
assert_eq!(profiles[0], before_profiles[0]);
|
||||
assert_eq!(targets[0], before_targets[0]);
|
||||
let edited = profiles.iter().find(|p| p.id == "main-profile").unwrap();
|
||||
assert_ne!(edited.target_id, "legacy-target");
|
||||
assert_eq!(edited.protocols, vec![Protocol::Tcp]);
|
||||
assert!(!edited.items[0].recursive);
|
||||
assert_eq!(
|
||||
targets
|
||||
.iter()
|
||||
.find(|t| t.id == edited.target_id)
|
||||
.unwrap()
|
||||
.host,
|
||||
"proxy.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_last_profile_requires_explicit_stop_and_does_not_call_helper() {
|
||||
let fixture = ApplyFixture::new("clear-running");
|
||||
fixture.seed_old_state();
|
||||
let before = fixture.storage.read_profiles().unwrap();
|
||||
let mut input = external_input();
|
||||
input.profile.id = Some("legacy".into());
|
||||
input.profile.target_id = "legacy-target".into();
|
||||
input.profile.enabled = false;
|
||||
input.profile.items.clear();
|
||||
input.disable_other_profiles = false;
|
||||
let helper = RecordingHelper::success();
|
||||
let error = run_apply(&fixture.storage, input, &helper).unwrap_err();
|
||||
assert_eq!(error.code(), "stop_before_clearing_route");
|
||||
assert_eq!(helper.calls.get(), 0);
|
||||
assert_eq!(fixture.storage.read_profiles().unwrap(), before);
|
||||
}
|
||||
|
||||
fn external_input() -> ApplyConfigurationInput {
|
||||
ApplyConfigurationInput {
|
||||
expected_revision: None,
|
||||
route_mode: ApplyRouteMode::External,
|
||||
profile: profile_input(),
|
||||
external_target: Some(TargetInput {
|
||||
@@ -299,6 +367,7 @@ fn test_proxyfier() -> DetectedProxyfier {
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
version: Some("2.2.1.0".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(windows)]
|
||||
use proxywarden_lib::process::AuthenticodePublisher;
|
||||
use proxywarden_lib::process::{verify_authenticode, AuthenticodeError};
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn bundled_windows_packet_filter_has_expected_trusted_publisher() {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("bundled/components/windows-packet-filter/Windows.Packet.Filter.3.6.2.1.x64.msi");
|
||||
|
||||
let verification = verify_authenticode(path).expect("bundled MSI should be verifiable");
|
||||
|
||||
assert!(verification.is_trusted);
|
||||
assert_eq!(
|
||||
verification.publisher,
|
||||
Some(AuthenticodePublisher {
|
||||
common_name: "The Anti-Cloud Corporation".to_owned(),
|
||||
organization: "The Anti-Cloud Corporation".to_owned(),
|
||||
})
|
||||
);
|
||||
assert_eq!(verification.status_code, 0);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn unsigned_regular_file_is_not_trusted() {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
|
||||
|
||||
let verification = verify_authenticode(path).expect("regular file should be inspectable");
|
||||
|
||||
assert!(!verification.is_trusted);
|
||||
assert_eq!(verification.publisher, None);
|
||||
assert_ne!(verification.status_code, 0);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn reparse_target_is_rejected_when_symlink_creation_is_available() {
|
||||
use std::{fs, os::windows::fs::symlink_file};
|
||||
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"proxywarden-authenticode-test-{}",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
fs::create_dir(&root).expect("test root should be creatable");
|
||||
let link = root.join("linked-target.exe");
|
||||
let target = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
|
||||
if let Err(error) = symlink_file(target, &link) {
|
||||
fs::remove_dir(&root).expect("test root should be removable");
|
||||
if error.raw_os_error() == Some(1314) {
|
||||
eprintln!("skipping reparse probe because this process lacks symlink privilege");
|
||||
return;
|
||||
}
|
||||
panic!("test symlink creation failed: {error}");
|
||||
}
|
||||
|
||||
let result = verify_authenticode(&link);
|
||||
fs::remove_file(&link).expect("test symlink should be removable");
|
||||
fs::remove_dir(&root).expect("test root should be removable");
|
||||
|
||||
assert_eq!(result, Err(AuthenticodeError::UnsafeTarget));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_target_fails_closed() {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("missing-signature-target.exe");
|
||||
|
||||
#[cfg(windows)]
|
||||
assert_eq!(
|
||||
verify_authenticode(path),
|
||||
Err(AuthenticodeError::InvalidTarget)
|
||||
);
|
||||
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(
|
||||
verify_authenticode(path),
|
||||
Err(AuthenticodeError::UnsupportedPlatform)
|
||||
);
|
||||
}
|
||||
+129
-414
@@ -1,26 +1,24 @@
|
||||
use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::commands::{
|
||||
self, apply_profiles_with_services, apply_profiles_with_services_and_detection, build_status,
|
||||
read_saved_state_with_proxifyre_config, resolve_component_statuses, resolve_preview,
|
||||
save_profile_to_storage, save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper,
|
||||
HelperApplyRequest, HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper,
|
||||
TargetInputDto,
|
||||
read_saved_state, resolve_component_statuses, resolve_preview, save_profile_to_storage,
|
||||
save_target_to_storage, Clock, CommandError, DetectedProxyApplyHelper, HelperApplyRequest,
|
||||
HelperApplyResult, ProfileInputDto, ProfileItemInputDto, ProxyApplyHelper, TargetInputDto,
|
||||
};
|
||||
use proxywarden_lib::component_detection::{
|
||||
DetectedProxyfier, ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry,
|
||||
};
|
||||
use proxywarden_lib::models::{
|
||||
self, ComponentId, ComponentState, ComponentStatus, Profile, ProfileItem, ProfileItemType,
|
||||
Protocol, ProxyProtocol, Target, TargetKind,
|
||||
self, ComponentId, ComponentState, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::proxifyre_ownership::ManagedProxiFyreOwnership;
|
||||
#[cfg(windows)]
|
||||
use proxywarden_lib::safe_fs;
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::net::TcpListener;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command as ProcessCommand;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
@@ -75,7 +73,7 @@ fn save_commands_normalize_and_persist_profile_and_target() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_state_bootstraps_from_existing_proxifyre_app_config() {
|
||||
fn saved_state_read_never_opportunistically_imports_proxifyre_config() {
|
||||
let root = test_root("proxifyre-config-import");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let install_dir = root.join("ProxiFyre");
|
||||
@@ -96,29 +94,19 @@ fn saved_state_bootstraps_from_existing_proxifyre_app_config() {
|
||||
}"#,
|
||||
)
|
||||
.expect("write proxifyre config");
|
||||
let source_before = fs::read(&config_path).expect("read proxifyre config before normal read");
|
||||
|
||||
let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path))
|
||||
.expect("state should import proxifyre app config");
|
||||
let state =
|
||||
read_saved_state(&storage).expect("normal read should ignore legacy runtime config");
|
||||
|
||||
assert_eq!(state.profiles.len(), 1);
|
||||
assert_eq!(state.targets.len(), 1);
|
||||
assert_eq!(state.profiles[0].id, "main-profile");
|
||||
assert_eq!(state.profiles[0].target_id, "main-proxy");
|
||||
assert_eq!(state.profiles[0].items.len(), 2);
|
||||
assert!(state.profiles.is_empty());
|
||||
assert!(state.targets.is_empty());
|
||||
assert!(!storage.paths().profiles_file.exists());
|
||||
assert!(!storage.paths().targets_file.exists());
|
||||
assert_eq!(
|
||||
state.profiles[0].items[0].item_type,
|
||||
ProfileItemType::Process
|
||||
fs::read(&config_path).expect("read proxifyre config after normal read"),
|
||||
source_before
|
||||
);
|
||||
assert_eq!(state.profiles[0].items[0].value, "Discord");
|
||||
assert_eq!(state.profiles[0].items[1].item_type, ProfileItemType::Exe);
|
||||
assert_eq!(state.profiles[0].items[1].value, r"C:\Games\Launcher.exe");
|
||||
assert_eq!(state.targets[0].id, "main-proxy");
|
||||
assert_eq!(state.targets[0].host, "127.0.0.1");
|
||||
assert_eq!(state.targets[0].port, 1090);
|
||||
|
||||
let persisted = storage.read_profiles().expect("read persisted profiles");
|
||||
assert_eq!(persisted.len(), 1);
|
||||
assert_eq!(persisted[0].items.len(), 2);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -152,8 +140,7 @@ fn saved_state_keeps_existing_proxywarden_profiles_over_proxifyre_config() {
|
||||
.write_targets(&[external_socks5_target()])
|
||||
.expect("write targets");
|
||||
|
||||
let state = read_saved_state_with_proxifyre_config(&storage, Some(&config_path))
|
||||
.expect("state should keep proxywarden storage");
|
||||
let state = read_saved_state(&storage).expect("state should keep proxywarden storage");
|
||||
|
||||
assert_eq!(state.profiles.len(), 1);
|
||||
assert_eq!(state.profiles[0].id, "discord");
|
||||
@@ -218,318 +205,6 @@ fn ping_proxy_target_reports_open_tcp_endpoint() {
|
||||
assert!(result.probes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn proxifyre_install_script_parses_as_powershell() {
|
||||
let root = test_root("proxifyre-install-script");
|
||||
fs::create_dir_all(&root).expect("test root should be created");
|
||||
|
||||
let script = commands::wrap_elevated_package_script(
|
||||
&commands::install_proxifyre_script(&root.join("proxifyre-app-config.json")),
|
||||
&root.join("install.log"),
|
||||
);
|
||||
let script_path = root.join("install.ps1");
|
||||
let mut script_bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
script_bytes.extend_from_slice(script.as_bytes());
|
||||
fs::write(&script_path, script_bytes).expect("script should be written");
|
||||
|
||||
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||
let parser = format!(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||
);
|
||||
let output = ProcessCommand::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||
.output()
|
||||
.expect("powershell parser should run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"install script should parse\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_install_script_uses_resilient_download_helpers() {
|
||||
let root = test_root("proxifyre-install-script-downloads");
|
||||
let script = commands::install_proxifyre_script(&root.join("proxifyre-app-config.json"));
|
||||
|
||||
assert!(script.contains("function Get-SafeUriForLog([string]$uri)"));
|
||||
assert!(script.contains("function Invoke-ReleaseApi([string]$uri, [string]$label)"));
|
||||
assert!(script.contains("function Resolve-ReleaseAsset("));
|
||||
assert!(script.contains("function Get-PinnedWindowsPacketFilterAsset([string]$arch)"));
|
||||
assert!(script.contains("function Get-PinnedProxiFyreAsset([string]$arch)"));
|
||||
assert!(
|
||||
script.contains("function Invoke-Download([string]$uri, [string]$path, [string]$label)")
|
||||
);
|
||||
assert!(script.contains("foreach ($attempt in 1..3)"));
|
||||
assert!(script.contains("Invoke-WebClientDownload $uri $partialPath"));
|
||||
assert!(script.contains("Invoke-CurlDownload $uri $partialPath"));
|
||||
assert!(script.contains("--user-agent 'proxywarden' --output $partialPath --url $uri"));
|
||||
assert!(script.contains("Move-Item -LiteralPath $partialPath -Destination $path -Force"));
|
||||
assert!(script.contains("function Get-BundledAsset([string]$pattern, [string]$label)"));
|
||||
assert!(script.contains("function Verify-BundledAssetHash([string]$path, [string]$label)"));
|
||||
assert!(script
|
||||
.contains("Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'"));
|
||||
assert!(script
|
||||
.contains("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'"));
|
||||
assert!(script.contains(
|
||||
"Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'"
|
||||
));
|
||||
assert!(
|
||||
script.contains("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'")
|
||||
);
|
||||
assert!(script.contains(
|
||||
"Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'"
|
||||
));
|
||||
assert!(script.contains("github.com/wiresock/ndisapi/releases/download"));
|
||||
assert!(script.contains("github.com/wiresock/proxifyre/releases/download"));
|
||||
assert!(script.contains(
|
||||
"[IO.File]::WriteAllText($markerPath, $markerJson, [Text.UTF8Encoding]::new($false))"
|
||||
));
|
||||
assert!(!script
|
||||
.contains("ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $markerPath -Encoding UTF8"));
|
||||
let packet_filter_step = script
|
||||
.find("Write-ProxyWardenProgress 'install' 'packet-filter'")
|
||||
.expect("packet filter install step should be present");
|
||||
let vc_runtime_step = script
|
||||
.find("Write-ProxyWardenProgress 'install' 'vc-runtime'")
|
||||
.expect("runtime install step should be present");
|
||||
let proxifyre_step = script
|
||||
.find("Write-ProxyWardenProgress 'install' 'proxifyre'")
|
||||
.expect("proxifyre install step should be present");
|
||||
assert!(packet_filter_step < vc_runtime_step);
|
||||
assert!(vc_runtime_step < proxifyre_step);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_install_script_prefers_bundled_assets_before_downloads() {
|
||||
let root = test_root("proxifyre-install-script-bundled-assets");
|
||||
let bundle_dir = root.join("bundle");
|
||||
let script = commands::install_proxifyre_script_with_bundle(
|
||||
&root.join("proxifyre-app-config.json"),
|
||||
Some(&bundle_dir),
|
||||
);
|
||||
|
||||
assert!(script.contains(&format!(
|
||||
"$bundledAssetDir = '{}'",
|
||||
bundle_dir.display().to_string().replace('\'', "''")
|
||||
)));
|
||||
assert!(script.contains("$script:bundledAssetDir = [string]$bundledAssetDir"));
|
||||
assert!(script.contains("function Get-BundledAssetDir"));
|
||||
assert!(script.contains("$manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json')"));
|
||||
assert!(script.contains("$script:bundledAssetManifest = Get-BundledAssetManifest"));
|
||||
assert!(script.contains("Copy-BundledAsset $bundledNdisPath $ndisPath"));
|
||||
assert!(script.contains("Copy-BundledAsset $bundledVcPath $vcRedistPath"));
|
||||
assert!(script.contains("Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath"));
|
||||
|
||||
let bundled_ndis = script
|
||||
.find("Get-BundledAsset $ndisPattern 'Windows Packet Filter'")
|
||||
.expect("ndis bundle check should be present");
|
||||
let online_ndis = script
|
||||
.find("Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter'")
|
||||
.expect("ndis online fallback should be present");
|
||||
assert!(bundled_ndis < online_ndis);
|
||||
|
||||
let bundled_proxifyre = script
|
||||
.find("Get-BundledAsset $proxifyrePattern 'ProxiFyre'")
|
||||
.expect("proxifyre bundle check should be present");
|
||||
let online_proxifyre = script
|
||||
.find("Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre'")
|
||||
.expect("proxifyre online fallback should be present");
|
||||
assert!(bundled_proxifyre < online_proxifyre);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_firewall_script_scopes_rules_to_managed_executable() {
|
||||
let executable =
|
||||
PathBuf::from(r"C:\Program Files\Proxy'Warden\components\ProxiFyre\ProxiFyre.exe");
|
||||
let script = commands::configure_proxifyre_firewall_script(&executable);
|
||||
|
||||
assert!(script.contains(
|
||||
"$exePath = 'C:\\Program Files\\Proxy''Warden\\components\\ProxiFyre\\ProxiFyre.exe'"
|
||||
));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Inbound"));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Outbound"));
|
||||
assert!(script.contains("-Program $exePath"));
|
||||
assert!(script.contains("Get-NetFirewallRule -Name $rule.Name"));
|
||||
assert!(!script.contains("Get-NetFirewallRule -DisplayName"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn proxifyre_firewall_script_parses_as_powershell() {
|
||||
let root = test_root("proxifyre-firewall-script");
|
||||
fs::create_dir_all(&root).expect("test root should be created");
|
||||
let script = commands::configure_proxifyre_firewall_script(
|
||||
&root.join("ProxiFyre").join("ProxiFyre.exe"),
|
||||
);
|
||||
let script_path = root.join("firewall.ps1");
|
||||
fs::write(&script_path, script).expect("script should be written");
|
||||
|
||||
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||
let parser = format!(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||
);
|
||||
let output = ProcessCommand::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||
.output()
|
||||
.expect("powershell parser should run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"firewall script should parse\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn proxifyre_uninstall_script_parses_as_powershell() {
|
||||
let root = test_root("proxifyre-uninstall-script");
|
||||
fs::create_dir_all(&root).expect("test root should be created");
|
||||
let detected = DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: root.join("ProxiFyre"),
|
||||
executable_path: root.join("ProxiFyre").join("ProxiFyre.exe"),
|
||||
config_path: Some(root.join("ProxiFyre").join("app-config.json")),
|
||||
running: false,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("stopped".to_string()),
|
||||
};
|
||||
|
||||
let script = commands::wrap_elevated_package_script(
|
||||
&commands::uninstall_proxifyre_script(Some(&detected), &managed_ownership(true)),
|
||||
&root.join("uninstall.log"),
|
||||
);
|
||||
let script_path = root.join("uninstall.ps1");
|
||||
let mut script_bytes = vec![0xEF, 0xBB, 0xBF];
|
||||
script_bytes.extend_from_slice(script.as_bytes());
|
||||
fs::write(&script_path, script_bytes).expect("script should be written");
|
||||
|
||||
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||
let parser = format!(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||
);
|
||||
let output = ProcessCommand::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||
.output()
|
||||
.expect("powershell parser should run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"uninstall script should parse\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxifyre_uninstall_script_removes_packet_filter_after_proxifyre() {
|
||||
let detected = DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
install_dir: PathBuf::from(r"C:\Tools\ProxiFyre"),
|
||||
executable_path: PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"),
|
||||
config_path: Some(PathBuf::from(r"C:\Tools\ProxiFyre\app-config.json")),
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
};
|
||||
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'"));
|
||||
assert!(script.contains("ArgumentList @('/x', $productCode, '/qn', '/norestart'"));
|
||||
assert!(script.contains("Uninstall-MsiProgram $packetFilter 'Windows Packet Filter'"));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Inbound"));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Outbound"));
|
||||
let proxifyre_step = script
|
||||
.find("Write-ProxyWardenProgress 'uninstall' 'proxifyre'")
|
||||
.expect("proxifyre uninstall step should be present");
|
||||
let packet_filter_step = script
|
||||
.find("Write-ProxyWardenProgress 'uninstall' 'packet-filter'")
|
||||
.expect("packet filter uninstall step should be present");
|
||||
assert!(proxifyre_step < packet_filter_step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxywarden_uninstall_hook_removes_only_managed_firewall_rules() {
|
||||
let script = include_str!("../bundled/cleanup/uninstall-managed-components.ps1");
|
||||
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Inbound"));
|
||||
assert!(script.contains("ProxyWarden.ProxiFyre.Outbound"));
|
||||
assert!(script.contains("Get-NetFirewallRule -Name $name"));
|
||||
assert!(!script.contains("Get-NetFirewallRule -DisplayName"));
|
||||
}
|
||||
|
||||
#[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(
|
||||
Path::new(r"C:\ProgramData\ProxyWarden\state\install-singbox.ps1"),
|
||||
Path::new(r"C:\ProgramData\ProxyWarden\state\install.log"),
|
||||
&[
|
||||
"-InstallRoot".to_string(),
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box".to_string(),
|
||||
"-ServiceName".to_string(),
|
||||
"ProxyWardenSingBox".to_string(),
|
||||
"-Uninstall".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert!(script.contains(
|
||||
"$installerArgs = @('-InstallRoot', 'C:\\Program Files\\ProxyWarden\\components\\sing-box'"
|
||||
));
|
||||
assert!(script.contains(
|
||||
"& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $installerPath @installerArgs"
|
||||
));
|
||||
assert!(
|
||||
!script.contains("Start-Process -FilePath 'powershell.exe' -ArgumentList $argumentList")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
|
||||
let root = test_root("apply");
|
||||
@@ -540,11 +215,6 @@ fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
|
||||
storage
|
||||
.write_targets(&[external_socks5_target()])
|
||||
.expect("write targets");
|
||||
write_json(
|
||||
&storage.paths().components_file,
|
||||
&[proxyfier_running(), singbox_missing()],
|
||||
);
|
||||
|
||||
let response = apply_profiles_with_services(
|
||||
&storage,
|
||||
&ProxiFyreAdapter::default(),
|
||||
@@ -566,6 +236,9 @@ fn apply_generates_derived_config_and_records_activity_with_mock_helper() {
|
||||
assert!(generated_contents.contains("\"appNames\""));
|
||||
assert!(generated_contents.contains("Discord"));
|
||||
assert!(generated_path.ends_with("proxifyre-app-config.json"));
|
||||
#[cfg(windows)]
|
||||
safe_fs::verify_path_protected_for_owner_admin_system(&generated_path)
|
||||
.expect("generated ProxiFyre config keeps restricted ACL");
|
||||
assert_eq!(activity.len(), 1);
|
||||
assert_eq!(activity[0].at, "2026-07-03T00:00:00Z");
|
||||
assert_eq!(activity[0].title, "Конфиг ProxiFyre создан");
|
||||
@@ -583,8 +256,6 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() {
|
||||
storage
|
||||
.write_targets(&[local_singbox_target()])
|
||||
.expect("write targets");
|
||||
write_json(&storage.paths().components_file, &[singbox_missing()]);
|
||||
|
||||
let error = apply_profiles_with_services_and_detection(
|
||||
&storage,
|
||||
&ProxiFyreAdapter::default(),
|
||||
@@ -607,7 +278,6 @@ fn apply_blocks_local_singbox_target_when_component_is_missing() {
|
||||
#[test]
|
||||
fn component_status_merges_detected_existing_proxifyre() {
|
||||
let components = resolve_component_statuses(
|
||||
Vec::new(),
|
||||
Some(DetectedProxyfier {
|
||||
engine: ProxyfierEngine::ProxiFyre,
|
||||
name: "ProxiFyre".to_string(),
|
||||
@@ -617,6 +287,7 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
running: true,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
version: Some("2.2.1.0".to_string()),
|
||||
}),
|
||||
None,
|
||||
);
|
||||
@@ -633,8 +304,8 @@ fn component_status_merges_detected_existing_proxifyre() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_status_does_not_keep_stale_installed_state_when_detection_is_missing() {
|
||||
let components = resolve_component_statuses(vec![proxyfier_running()], None, None);
|
||||
fn component_status_reports_missing_when_detection_is_missing() {
|
||||
let components = resolve_component_statuses(None, None);
|
||||
let proxyfier = components
|
||||
.iter()
|
||||
.find(|component| component.id == ComponentId::Proxyfier)
|
||||
@@ -647,18 +318,37 @@ fn component_status_does_not_keep_stale_installed_state_when_detection_is_missin
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||
fn managed_current_apply_stages_generated_config_without_writing_sealed_runtime_snapshot() {
|
||||
let root = test_root("detected-proxifyre");
|
||||
let install_dir = root.join("ProxiFyre");
|
||||
fs::create_dir_all(&install_dir).expect("install dir");
|
||||
fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe");
|
||||
fs::write(install_dir.join("app-config.json"), "{}").expect("existing config");
|
||||
fs::write(
|
||||
install_dir.join("proxywarden-component.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": install_dir.display().to_string(),
|
||||
"packetFilterInstalledByProxyWarden": false
|
||||
}))
|
||||
.expect("marker JSON"),
|
||||
)
|
||||
.expect("managed marker");
|
||||
let generated_config = root.join("generated").join("proxifyre-app-config.json");
|
||||
let host = DetectionHost::new()
|
||||
.with_registry("ProxiFyre", &install_dir)
|
||||
.with_path(&install_dir)
|
||||
.with_path(&install_dir.join("ProxiFyre.exe"));
|
||||
let helper = DetectedProxyApplyHelper::from(host);
|
||||
.with_path(&install_dir.join("ProxiFyre.exe"))
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
&format!(
|
||||
r#""{}" --service"#,
|
||||
install_dir.join("ProxiFyre.exe").display()
|
||||
),
|
||||
);
|
||||
let helper = DetectedProxyApplyHelper::with_current_root(host, install_dir.clone());
|
||||
|
||||
let result = helper
|
||||
.apply_proxy_config(HelperApplyRequest {
|
||||
@@ -668,17 +358,61 @@ fn detected_proxy_apply_helper_writes_proxifyre_app_config() {
|
||||
})
|
||||
.expect("detected helper should apply");
|
||||
|
||||
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());
|
||||
assert_eq!(result.action, "proxifyre.stage-managed-config");
|
||||
assert_eq!(
|
||||
fs::read_to_string(install_dir.join("app-config.json"))
|
||||
.expect("read unchanged runtime snapshot"),
|
||||
"{}"
|
||||
);
|
||||
assert!(!install_dir.join("app-config.json.bak").exists());
|
||||
assert!(result.message.contains("следующем явном запуске"));
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_proxy_apply_helper_does_not_write_for_foreign_service_collision() {
|
||||
let root = test_root("detected-proxifyre-foreign-service");
|
||||
let install_dir = root.join("ProxiFyre");
|
||||
let config_path = install_dir.join("app-config.json");
|
||||
fs::create_dir_all(&install_dir).expect("install dir");
|
||||
fs::write(install_dir.join("ProxiFyre.exe"), "mock exe").expect("mock exe");
|
||||
fs::write(&config_path, "original").expect("existing config");
|
||||
fs::write(
|
||||
install_dir.join("proxywarden-component.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": install_dir.display().to_string(),
|
||||
"packetFilterInstalledByProxyWarden": false
|
||||
}))
|
||||
.expect("marker JSON"),
|
||||
)
|
||||
.expect("managed marker");
|
||||
let generated_config = root.join("generated").join("proxifyre-app-config.json");
|
||||
let host = DetectionHost::new()
|
||||
.with_path(&install_dir)
|
||||
.with_path(&install_dir.join("ProxiFyre.exe"))
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Foreign\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
let helper = DetectedProxyApplyHelper::with_current_root(host, install_dir.clone());
|
||||
|
||||
let error = helper
|
||||
.apply_proxy_config(HelperApplyRequest {
|
||||
adapter_id: "proxifyre",
|
||||
config_path: &generated_config,
|
||||
config_contents: r#"{"proxies":[]}"#,
|
||||
})
|
||||
.expect_err("foreign service collision must fail before config write");
|
||||
|
||||
assert_eq!(error.code, "ownership_mismatch");
|
||||
assert_eq!(fs::read_to_string(&config_path).unwrap(), "original");
|
||||
assert!(!install_dir.join("app-config.json.bak").exists());
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -746,6 +480,7 @@ impl Clock for FixedClock {
|
||||
struct DetectionHost {
|
||||
paths: HashSet<String>,
|
||||
registry: Vec<RegistryInstallEntry>,
|
||||
service_paths: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl DetectionHost {
|
||||
@@ -766,6 +501,12 @@ impl DetectionHost {
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
fn with_service_path(mut self, service_name: &str, path_name: &str) -> Self {
|
||||
self.service_paths
|
||||
.insert(service_name.to_ascii_lowercase(), path_name.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyfierDetectionHost for DetectionHost {
|
||||
@@ -782,12 +523,33 @@ impl ProxyfierDetectionHost for DetectionHost {
|
||||
}
|
||||
|
||||
fn service_status(&self, _service_name: &str) -> Option<String> {
|
||||
None
|
||||
self.service_paths
|
||||
.contains_key(&_service_name.to_ascii_lowercase())
|
||||
.then(|| "stopped".to_string())
|
||||
}
|
||||
|
||||
fn service_info(
|
||||
&self,
|
||||
service_name: &str,
|
||||
) -> Option<proxywarden_lib::component_detection::DetectedService> {
|
||||
self.service_paths
|
||||
.get(&service_name.to_ascii_lowercase())
|
||||
.map(
|
||||
|path_name| proxywarden_lib::component_detection::DetectedService {
|
||||
name: service_name.to_string(),
|
||||
status: "stopped".to_string(),
|
||||
path_name: Some(path_name.clone()),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||
self.registry.clone()
|
||||
}
|
||||
|
||||
fn read_text(&self, path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_path(path: &Path) -> String {
|
||||
@@ -810,14 +572,6 @@ fn cleanup(root: &Path) {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create json parent dir");
|
||||
}
|
||||
let contents = serde_json::to_vec_pretty(value).expect("serialize json");
|
||||
fs::write(path, contents).expect("write json");
|
||||
}
|
||||
|
||||
fn discord_profile(target_id: &str) -> Profile {
|
||||
Profile {
|
||||
id: "discord".to_string(),
|
||||
@@ -856,42 +610,3 @@ fn local_singbox_target() -> Target {
|
||||
requires_component: Some(ComponentId::Singbox),
|
||||
}
|
||||
}
|
||||
|
||||
fn proxyfier_running() -> ComponentStatus {
|
||||
ComponentStatus {
|
||||
id: ComponentId::Proxyfier,
|
||||
name: "ProxiFyre".to_string(),
|
||||
state: ComponentState::Running,
|
||||
installed: true,
|
||||
running: true,
|
||||
version: Some("2.2.1".to_string()),
|
||||
path: Some(r"C:\Tools\ProxiFyre".to_string()),
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: Some("running".to_string()),
|
||||
problems: Vec::new(),
|
||||
actions: vec!["Restart".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn singbox_missing() -> ComponentStatus {
|
||||
ComponentStatus {
|
||||
id: ComponentId::Singbox,
|
||||
name: "Локальный sing-box".to_string(),
|
||||
state: ComponentState::Missing,
|
||||
installed: false,
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some("ProxyWardenSingBox".to_string()),
|
||||
service_status: None,
|
||||
problems: vec!["Локальный sing-box не установлен".to_string()],
|
||||
actions: vec!["Установить локальный sing-box".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_ownership(remove_packet_filter: bool) -> ManagedProxiFyreOwnership {
|
||||
ManagedProxiFyreOwnership {
|
||||
service_name: "ProxiFyreService".to_string(),
|
||||
remove_packet_filter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
use proxywarden_lib::component_catalog::{
|
||||
parse_bundled_catalog_if_present, parse_catalog, validate_bundle, AssetArch, ComponentId,
|
||||
TargetArch,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn parses_exact_x64_catalog_and_all_trust_policy_variants() {
|
||||
let catalog = parse_value(&valid_catalog()).expect("valid catalog must parse");
|
||||
|
||||
assert_eq!(catalog.target_arch, TargetArch::X64);
|
||||
assert_eq!(catalog.components.len(), 5);
|
||||
assert_eq!(
|
||||
catalog
|
||||
.components
|
||||
.iter()
|
||||
.find(|component| component.id == ComponentId::Winsw)
|
||||
.expect("WinSW entry")
|
||||
.asset_arch,
|
||||
AssetArch::Anycpu
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unknown_schema_arch_fields_duplicates_and_incomplete_set() {
|
||||
assert_rejected(mutate(|catalog| catalog["schemaVersion"] = json!(2)));
|
||||
assert_rejected(mutate(|catalog| catalog["targetArch"] = json!("arm64")));
|
||||
assert_rejected(mutate(|catalog| catalog["unexpected"] = json!(true)));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["unexpected"] = json!(true);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "windows-packet-filter")["sourceUrl"] = json!(
|
||||
"https://github.com/attacker/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi"
|
||||
);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["sourceUrl"] =
|
||||
json!("https://attacker.example/vc_redist.x64.exe");
|
||||
component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["allowedSourceHosts"] =
|
||||
json!(["attacker.example"]);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["license"]["unexpected"] = json!(true);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["updateTrustPolicy"]["unexpected"] = json!(true);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "windows-packet-filter")["id"] = json!("proxifyre");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "windows-packet-filter")["installRole"] = json!("proxifyre-runtime");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
catalog["components"]
|
||||
.as_array_mut()
|
||||
.expect("components array")
|
||||
.pop();
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_component_role_or_architecture() {
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["installRole"] = json!("packet-filter-driver");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["assetArch"] = json!("anycpu");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "winsw")["assetArch"] = json!("x64");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "winsw")["effectiveTarget"] = json!("anycpu");
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsafe_paths_hash_size_license_version_and_source() {
|
||||
for invalid_path in [
|
||||
"../asset.zip",
|
||||
"proxifyre/../asset.zip",
|
||||
"proxifyre\\asset.zip",
|
||||
"/proxifyre/asset.zip",
|
||||
"proxifyre/CON.zip",
|
||||
"other/asset.zip",
|
||||
] {
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["assetPath"] = json!(invalid_path);
|
||||
}));
|
||||
}
|
||||
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["license"]["path"] = json!("../LICENSE.txt");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "windows-packet-filter")["license"]["path"] =
|
||||
json!("proxifyre/WPF-LICENSE.txt");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "windows-packet-filter")["license"]["path"] =
|
||||
json!("proxifyre/LICENSE.txt");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["license"]["id"] = json!("GPL 3");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["sha256"] = json!("A".repeat(64));
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["sha256"] = json!("a".repeat(63));
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["size"] = json!(0);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["version"] = json!("2.4.0-beta.1");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "winsw")["productVersion"] = json!("2.12.0-rc.1");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["sourceUrl"] = json!(
|
||||
"http://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip"
|
||||
);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["sourceUrl"] = json!(
|
||||
"https://user:secret@github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip"
|
||||
);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["sourceUrl"] =
|
||||
json!("https://github.com/wiresock/proxifyre/releases/download/v2.4.0/wrong.zip");
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_trust_policies() {
|
||||
for id in ["proxifyre", "windows-packet-filter", "sing-box"] {
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, id)["updateTrustPolicy"] = json!({
|
||||
"type": "bundledOnlyNoIndependentProof",
|
||||
"reason": "Wrong policy for this component."
|
||||
});
|
||||
}));
|
||||
}
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["updateTrustPolicy"] = json!({
|
||||
"type": "bundledOnlyNoIndependentProof",
|
||||
"reason": "Wrong policy for this component."
|
||||
});
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "winsw")["updateTrustPolicy"] = json!({
|
||||
"type": "githubReleaseDigest",
|
||||
"repository": "winsw/winsw",
|
||||
"tagPattern": "v*",
|
||||
"assetPattern": "WinSW.NET461.exe",
|
||||
"requireStable": true
|
||||
});
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["updateTrustPolicy"]["requireStable"] = json!(false);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["updateTrustPolicy"]["repository"] =
|
||||
json!("attacker/proxifyre");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["updateTrustPolicy"]["tagPattern"] = json!("v**");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["updateTrustPolicy"]["authenticodePublishers"] =
|
||||
json!([]);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["allowedSourceHosts"] = json!([]);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["publishers"] = json!([" "]);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["assetPattern"] =
|
||||
json!("other.exe");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "winsw")["updateTrustPolicy"]["type"] = json!("unknownPolicy");
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_component_policy_allowlist_expansion() {
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["updateTrustPolicy"]["repository"] =
|
||||
json!("Wiresock/proxifyre");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["updateTrustPolicy"]["tagPattern"] = json!("v2.*");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["updateTrustPolicy"]["assetPattern"] = json!("*");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "proxifyre")["updateTrustPolicy"]["authenticodePublishers"] =
|
||||
Value::Null;
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "windows-packet-filter")["updateTrustPolicy"]["assetPattern"] =
|
||||
json!("Windows.Packet.Filter.*");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "windows-packet-filter")["updateTrustPolicy"]
|
||||
["authenticodePublishers"] =
|
||||
json!(["The Anti-Cloud Corporation", "Unexpected Publisher"]);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "sing-box")["updateTrustPolicy"]["repository"] =
|
||||
json!("sagernet/sing-box");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "sing-box")["updateTrustPolicy"]["authenticodePublishers"] =
|
||||
json!(["Unexpected Publisher"]);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["allowedSourceHosts"] =
|
||||
json!(["aka.ms", "attacker.example"]);
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["assetPattern"] = json!("*");
|
||||
}));
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["updateTrustPolicy"]["publishers"] =
|
||||
json!(["Microsoft Corporation", "Unexpected Publisher"]);
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_component_license_ids() {
|
||||
for (id, wrong_license) in [
|
||||
("proxifyre", "MIT"),
|
||||
("windows-packet-filter", "GPL-3.0-only"),
|
||||
("vc-runtime", "LicenseRef-Microsoft-VCRedist"),
|
||||
("sing-box", "GPL-3.0-or-later"),
|
||||
("winsw", "AGPL-3.0-only"),
|
||||
] {
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, id)["license"]["id"] = json!(wrong_license);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unpinned_or_wrong_vc_runtime_source() {
|
||||
for source in [
|
||||
"https://aka.ms/vs/17/release/vc_redist.x64.exe",
|
||||
"https://aka.ms/vs/18/release/vc_redist.x64.exe",
|
||||
"https://aka.ms/vs/18/release/14.50.35719/VC_redist.x64.exe",
|
||||
] {
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["sourceUrl"] = json!(source);
|
||||
}));
|
||||
}
|
||||
assert_rejected(mutate(|catalog| {
|
||||
component_mut(catalog, "vc-runtime")["version"] = json!("14.50.35719.0");
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_exact_bundle_contents_hashes_sizes_and_licenses() {
|
||||
let bundle = TestBundle::new();
|
||||
let catalog = validate_bundle(bundle.path()).expect("complete bundle must validate");
|
||||
|
||||
assert_eq!(catalog.components.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_extra_or_changed_package_assets() {
|
||||
let missing = TestBundle::new();
|
||||
fs::remove_file(missing.path().join(asset_path("proxifyre"))).expect("remove fixture asset");
|
||||
assert!(validate_bundle(missing.path()).is_err());
|
||||
|
||||
let extra = TestBundle::new();
|
||||
fs::write(extra.path().join("unexpected.bin"), b"extra").expect("write extra file");
|
||||
assert!(validate_bundle(extra.path()).is_err());
|
||||
|
||||
let changed = TestBundle::new();
|
||||
let path = changed.path().join(asset_path("proxifyre"));
|
||||
let original = fs::read(&path).expect("read fixture asset");
|
||||
fs::write(&path, vec![b'x'; original.len()]).expect("change fixture asset");
|
||||
assert!(validate_bundle(changed.path()).is_err());
|
||||
|
||||
let wrong_size = TestBundle::new();
|
||||
let mut catalog: Value = serde_json::from_slice(
|
||||
&fs::read(wrong_size.path().join("catalog.json")).expect("read fixture catalog"),
|
||||
)
|
||||
.expect("parse fixture catalog");
|
||||
component_mut(&mut catalog, "proxifyre")["size"] = json!(999);
|
||||
write_catalog(wrong_size.path(), &catalog);
|
||||
assert!(validate_bundle(wrong_size.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_or_empty_license_copy() {
|
||||
let missing = TestBundle::new();
|
||||
fs::remove_file(missing.path().join("proxifyre/LICENSE.txt")).expect("remove fixture license");
|
||||
assert!(validate_bundle(missing.path()).is_err());
|
||||
|
||||
let empty = TestBundle::new();
|
||||
fs::write(empty.path().join("proxifyre/LICENSE.txt"), b"").expect("empty fixture license");
|
||||
assert!(validate_bundle(empty.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_bundle_parse_is_none_only_when_catalog_is_absent() {
|
||||
let absent = TempDirectory::new();
|
||||
assert!(parse_bundled_catalog_if_present(absent.path())
|
||||
.expect("absent catalog is allowed")
|
||||
.is_none());
|
||||
|
||||
let present = TestBundle::new();
|
||||
assert!(parse_bundled_catalog_if_present(present.path())
|
||||
.expect("present catalog must validate")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_bundle_validates_when_catalog_exists() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("bundled")
|
||||
.join("components");
|
||||
|
||||
let catalog = validate_bundle(&root).expect("production component bundle must validate");
|
||||
assert_eq!(catalog.components.len(), 5);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn rejects_reparse_bundle_root_and_nested_directory() {
|
||||
let target = TestBundle::new();
|
||||
let junctions = TempDirectory::new();
|
||||
let root_junction = junctions.path().join("bundle-root-junction");
|
||||
let root_guard = create_junction(&root_junction, target.path());
|
||||
assert!(validate_bundle(&root_junction).is_err());
|
||||
drop(root_guard);
|
||||
|
||||
let nested = TestBundle::new();
|
||||
let proxifyre_target = junctions.path().join("proxifyre-target");
|
||||
fs::rename(nested.path().join("proxifyre"), &proxifyre_target)
|
||||
.expect("move fixture component behind a junction");
|
||||
let nested_guard = create_junction(&nested.path().join("proxifyre"), &proxifyre_target);
|
||||
assert!(validate_bundle(nested.path()).is_err());
|
||||
drop(nested_guard);
|
||||
}
|
||||
|
||||
fn valid_catalog() -> Value {
|
||||
json!({
|
||||
"schemaVersion": 1,
|
||||
"targetArch": "x64",
|
||||
"components": [
|
||||
component(
|
||||
"proxifyre",
|
||||
"2.4.0",
|
||||
"ProxiFyre-v2.4.0-x64-signed.zip",
|
||||
"x64",
|
||||
"https://github.com/wiresock/proxifyre/releases/download/v2.4.0/ProxiFyre-v2.4.0-x64-signed.zip",
|
||||
json!({
|
||||
"type": "githubReleaseDigest",
|
||||
"repository": "wiresock/proxifyre",
|
||||
"tagPattern": "v*",
|
||||
"assetPattern": "ProxiFyre-v*-x64-signed.zip",
|
||||
"requireStable": true,
|
||||
"authenticodePublishers": ["The Anti-Cloud Corporation"]
|
||||
})
|
||||
),
|
||||
component(
|
||||
"windows-packet-filter",
|
||||
"3.6.2",
|
||||
"Windows.Packet.Filter.3.6.2.1.x64.msi",
|
||||
"x64",
|
||||
"https://github.com/wiresock/ndisapi/releases/download/v3.6.2/Windows.Packet.Filter.3.6.2.1.x64.msi",
|
||||
json!({
|
||||
"type": "githubReleaseDigest",
|
||||
"repository": "wiresock/ndisapi",
|
||||
"tagPattern": "v*",
|
||||
"assetPattern": "Windows.Packet.Filter.*.x64.msi",
|
||||
"requireStable": true,
|
||||
"authenticodePublishers": ["The Anti-Cloud Corporation"]
|
||||
})
|
||||
),
|
||||
component(
|
||||
"vc-runtime",
|
||||
"14.51.36247.0",
|
||||
"VC_redist.x64.exe",
|
||||
"x64",
|
||||
"https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe",
|
||||
json!({
|
||||
"type": "buildTimeOnlyAuthenticode",
|
||||
"allowedSourceHosts": ["aka.ms"],
|
||||
"assetPattern": "VC_redist.x64.exe",
|
||||
"publishers": ["Microsoft Corporation"]
|
||||
})
|
||||
),
|
||||
component(
|
||||
"sing-box",
|
||||
"1.13.19",
|
||||
"sing-box-1.13.19-windows-amd64.zip",
|
||||
"x64",
|
||||
"https://github.com/SagerNet/sing-box/releases/download/v1.13.19/sing-box-1.13.19-windows-amd64.zip",
|
||||
json!({
|
||||
"type": "githubReleaseDigest",
|
||||
"repository": "SagerNet/sing-box",
|
||||
"tagPattern": "v*",
|
||||
"assetPattern": "sing-box-*-windows-amd64.zip",
|
||||
"requireStable": true
|
||||
})
|
||||
),
|
||||
component(
|
||||
"winsw",
|
||||
"2.12.0",
|
||||
"WinSW.NET461.exe",
|
||||
"anycpu",
|
||||
"https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe",
|
||||
json!({
|
||||
"type": "bundledOnlyNoIndependentProof",
|
||||
"reason": "Upstream provides no independent digest or Authenticode proof for this asset."
|
||||
})
|
||||
)
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
fn component(
|
||||
id: &str,
|
||||
version: &str,
|
||||
asset_name: &str,
|
||||
asset_arch: &str,
|
||||
source_url: &str,
|
||||
update_trust_policy: Value,
|
||||
) -> Value {
|
||||
let bytes = asset_bytes(id);
|
||||
let (license_id, install_role) = match id {
|
||||
"proxifyre" => ("AGPL-3.0-only", "proxifyre-runtime"),
|
||||
"windows-packet-filter" => ("MIT", "packet-filter-driver"),
|
||||
"vc-runtime" => (
|
||||
"LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026",
|
||||
"vc-runtime-prerequisite",
|
||||
),
|
||||
"sing-box" => ("LicenseRef-Sing-Box-Project", "sing-box-runtime"),
|
||||
"winsw" => ("MIT", "sing-box-service-wrapper"),
|
||||
_ => panic!("unknown fixture component"),
|
||||
};
|
||||
json!({
|
||||
"id": id,
|
||||
"version": version,
|
||||
"fileVersion": if id == "windows-packet-filter" { "3.6.2.1" } else { version },
|
||||
"productVersion": match id {
|
||||
"windows-packet-filter" => "3.6.2.1",
|
||||
"winsw" => "2.12.0+eef5c6a",
|
||||
_ => version
|
||||
},
|
||||
"assetPath": format!("{id}/{asset_name}"),
|
||||
"assetArch": asset_arch,
|
||||
"effectiveTarget": "x64",
|
||||
"sha256": sha256(bytes),
|
||||
"size": bytes.len(),
|
||||
"sourceUrl": source_url,
|
||||
"license": {
|
||||
"id": license_id,
|
||||
"path": format!("{id}/LICENSE.txt")
|
||||
},
|
||||
"installRole": install_role,
|
||||
"updateTrustPolicy": update_trust_policy
|
||||
})
|
||||
}
|
||||
|
||||
fn asset_bytes(id: &str) -> &'static [u8] {
|
||||
match id {
|
||||
"proxifyre" => b"fixture-proxifyre-asset",
|
||||
"windows-packet-filter" => b"fixture-packet-filter-asset",
|
||||
"vc-runtime" => b"fixture-vc-runtime-asset",
|
||||
"sing-box" => b"fixture-sing-box-asset",
|
||||
"winsw" => b"fixture-winsw-asset",
|
||||
_ => panic!("unknown fixture component"),
|
||||
}
|
||||
}
|
||||
|
||||
fn asset_path(id: &str) -> String {
|
||||
valid_catalog()["components"]
|
||||
.as_array()
|
||||
.expect("components array")
|
||||
.iter()
|
||||
.find(|component| component["id"] == id)
|
||||
.expect("fixture component")["assetPath"]
|
||||
.as_str()
|
||||
.expect("asset path")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn sha256(bytes: &[u8]) -> String {
|
||||
format!("{:x}", Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn mutate(change: impl FnOnce(&mut Value)) -> Value {
|
||||
let mut catalog = valid_catalog();
|
||||
change(&mut catalog);
|
||||
catalog
|
||||
}
|
||||
|
||||
fn component_mut<'a>(catalog: &'a mut Value, id: &str) -> &'a mut Value {
|
||||
catalog["components"]
|
||||
.as_array_mut()
|
||||
.expect("components array")
|
||||
.iter_mut()
|
||||
.find(|component| component["id"] == id)
|
||||
.expect("fixture component")
|
||||
}
|
||||
|
||||
fn parse_value(
|
||||
value: &Value,
|
||||
) -> Result<
|
||||
proxywarden_lib::component_catalog::ComponentCatalog,
|
||||
proxywarden_lib::component_catalog::ComponentCatalogError,
|
||||
> {
|
||||
parse_catalog(&serde_json::to_vec(value).expect("serialize fixture catalog"))
|
||||
}
|
||||
|
||||
fn assert_rejected(value: Value) {
|
||||
assert!(
|
||||
parse_value(&value).is_err(),
|
||||
"catalog unexpectedly passed: {value}"
|
||||
);
|
||||
}
|
||||
|
||||
fn write_catalog(root: &Path, catalog: &Value) {
|
||||
fs::write(
|
||||
root.join("catalog.json"),
|
||||
serde_json::to_vec_pretty(catalog).expect("serialize fixture catalog"),
|
||||
)
|
||||
.expect("write fixture catalog");
|
||||
}
|
||||
|
||||
struct TestBundle {
|
||||
directory: TempDirectory,
|
||||
}
|
||||
|
||||
impl TestBundle {
|
||||
fn new() -> Self {
|
||||
let directory = TempDirectory::new();
|
||||
let catalog = valid_catalog();
|
||||
for component in catalog["components"].as_array().expect("components array") {
|
||||
let id = component["id"].as_str().expect("component id");
|
||||
let asset_path = component["assetPath"].as_str().expect("asset path");
|
||||
let license_path = component["license"]["path"].as_str().expect("license path");
|
||||
fs::create_dir_all(
|
||||
directory
|
||||
.path()
|
||||
.join(asset_path)
|
||||
.parent()
|
||||
.expect("asset parent"),
|
||||
)
|
||||
.expect("create component directory");
|
||||
fs::write(directory.path().join(asset_path), asset_bytes(id))
|
||||
.expect("write fixture asset");
|
||||
fs::write(
|
||||
directory.path().join(license_path),
|
||||
format!("License fixture for {id}\n"),
|
||||
)
|
||||
.expect("write fixture license");
|
||||
}
|
||||
write_catalog(directory.path(), &catalog);
|
||||
Self { directory }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
self.directory.path()
|
||||
}
|
||||
}
|
||||
|
||||
struct TempDirectory {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TempDirectory {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"proxywarden-component-catalog-test-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&path).expect("create temporary test directory");
|
||||
Self { path }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDirectory {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct JunctionGuard {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for JunctionGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn create_junction(path: &Path, target: &Path) -> JunctionGuard {
|
||||
let output = Command::new("cmd")
|
||||
.args(["/d", "/c", "mklink", "/J"])
|
||||
.arg(path)
|
||||
.arg(target)
|
||||
.output()
|
||||
.expect("run mklink for reparse-point fixture");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"mklink failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
JunctionGuard {
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,15 @@
|
||||
#[cfg(windows)]
|
||||
use proxywarden_lib::component_detection::SystemProxyfierDetectionHost;
|
||||
use proxywarden_lib::component_detection::{
|
||||
detect_proxyfier_install_with_host, detect_singbox_install_with_host,
|
||||
proxyfier_component_from_detection, singbox_component_from_detection, ProxyfierDetectionHost,
|
||||
ProxyfierEngine, RegistryInstallEntry,
|
||||
has_additional_matching_legacy_proxifyre_service_with_host, inventory_proxyfier_with_host,
|
||||
inventory_singbox_with_host, matches_legacy_proxifyre_2_2_1_manifest,
|
||||
proxyfier_component_from_detection, proxyfier_component_from_inventory,
|
||||
service_executable_from_path_name, singbox_component_from_detection, LegacyPackageFileIdentity,
|
||||
ProxyfierDetectionHost, ProxyfierEngine, RegistryInstallEntry, LEGACY_PROXIFYRE_2_2_1_MANIFEST,
|
||||
};
|
||||
use proxywarden_lib::component_inventory::{
|
||||
BinaryIdentityEvidence, ComponentClassification, OWNERSHIP_MISMATCH,
|
||||
};
|
||||
use proxywarden_lib::models::ComponentState;
|
||||
use std::{
|
||||
@@ -18,7 +26,9 @@ fn detects_existing_proxifyre_from_registry_install_location() {
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
)
|
||||
.with_known_binary(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
|
||||
.with_version(r"C:\Tools\ProxiFyre\ProxiFyre.exe", "2.2.1.0");
|
||||
|
||||
let detected = detect_proxyfier_install_with_host(&host)
|
||||
.expect("existing ProxiFyre install should be detected");
|
||||
@@ -32,6 +42,7 @@ fn detects_existing_proxifyre_from_registry_install_location() {
|
||||
assert!(detected.running);
|
||||
assert_eq!(detected.service_name, Some("ProxiFyreService".to_string()));
|
||||
assert_eq!(detected.service_status, Some("running".to_string()));
|
||||
assert_eq!(detected.version, Some("2.2.1.0".to_string()));
|
||||
|
||||
let component = proxyfier_component_from_detection(Some(&detected));
|
||||
assert_eq!(component.state, ComponentState::Running);
|
||||
@@ -40,9 +51,72 @@ fn detects_existing_proxifyre_from_registry_install_location() {
|
||||
assert_eq!(component.path, Some(r"C:\Tools\ProxiFyre".to_string()));
|
||||
assert_eq!(component.service_name, Some("ProxiFyreService".to_string()));
|
||||
assert_eq!(component.service_status, Some("running".to_string()));
|
||||
assert_eq!(component.version, Some("2.2.1.0".to_string()));
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_current_proxifyre_only_with_strong_marker_and_exact_service_path() {
|
||||
let root = r"C:\Program Files\ProxyWarden\components\ProxiFyre";
|
||||
let executable = r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe";
|
||||
let marker = serde_json::json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": root,
|
||||
"packetFilterInstalledByProxyWarden": false
|
||||
})
|
||||
.to_string();
|
||||
let host = MockHost::new()
|
||||
.with_path(root)
|
||||
.with_path(executable)
|
||||
.with_text(
|
||||
r"C:\Program Files\ProxyWarden\components\ProxiFyre\proxywarden-component.json",
|
||||
&marker,
|
||||
)
|
||||
.with_stopped_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
)
|
||||
.with_version(executable, "2.4.0.0");
|
||||
|
||||
let detected = detect_proxyfier_install_with_host(&host).expect("managed current ProxiFyre");
|
||||
assert_eq!(detected.install_dir, PathBuf::from(root));
|
||||
assert_eq!(detected.version, Some("2.4.0.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_proxifyre_with_foreign_same_name_service_is_ownership_mismatch() {
|
||||
let root = r"C:\Program Files\ProxyWarden\components\ProxiFyre";
|
||||
let executable = r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe";
|
||||
let marker = serde_json::json!({
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": root,
|
||||
"packetFilterInstalledByProxyWarden": false
|
||||
})
|
||||
.to_string();
|
||||
let host = MockHost::new()
|
||||
.with_path(root)
|
||||
.with_path(executable)
|
||||
.with_text(
|
||||
r"C:\Program Files\ProxyWarden\components\ProxiFyre\proxywarden-component.json",
|
||||
&marker,
|
||||
)
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Foreign\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
|
||||
let inventory = inventory_proxyfier_with_host(&host);
|
||||
assert_eq!(inventory.classification(), ComponentClassification::Foreign);
|
||||
assert_eq!(
|
||||
inventory.selected_candidate().unwrap().issues[0].code,
|
||||
OWNERSHIP_MISMATCH
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_empty_common_proxifyre_folder_without_executable() {
|
||||
let host = MockHost::new().with_path(r"C:\Tools\ProxiFyre");
|
||||
@@ -65,19 +139,14 @@ fn ignores_plain_proxifier_install() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_override_can_point_to_portable_proxifyre_install() {
|
||||
fn env_override_does_not_make_portable_proxifyre_managed() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_PROXIFYRE_ROOT", r"D:\Portable\ProxiFyre")
|
||||
.with_path(r"D:\Portable\ProxiFyre\ProxiFyre.exe");
|
||||
|
||||
let detected = detect_proxyfier_install_with_host(&host)
|
||||
.expect("env override should be checked before common paths");
|
||||
|
||||
assert_eq!(detected.engine, ProxyfierEngine::ProxiFyre);
|
||||
assert_eq!(
|
||||
detected.executable_path,
|
||||
PathBuf::from(r"D:\Portable\ProxiFyre\ProxiFyre.exe")
|
||||
);
|
||||
assert!(detect_proxyfier_install_with_host(&host).is_none());
|
||||
let inventory = inventory_proxyfier_with_host(&host);
|
||||
assert_eq!(inventory.classification(), ComponentClassification::Foreign);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -88,7 +157,8 @@ fn reports_stopped_proxifyre_service_when_executable_exists() {
|
||||
.with_stopped_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
);
|
||||
)
|
||||
.with_known_binary(r"C:\Tools\ProxiFyre\ProxiFyre.exe");
|
||||
|
||||
let detected =
|
||||
detect_proxyfier_install_with_host(&host).expect("proxifyre executable should be detected");
|
||||
@@ -102,6 +172,45 @@ fn reports_stopped_proxifyre_service_when_executable_exists() {
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_and_alias_same_root_remain_discoverable_but_cutover_is_ambiguous() {
|
||||
let root = Path::new(r"C:\Tools\ProxiFyre");
|
||||
let executable = root.join("ProxiFyre.exe");
|
||||
let host = MockHost::new()
|
||||
.with_path(executable.to_str().expect("fixture path"))
|
||||
.with_service_path(
|
||||
"ProxiFyreService",
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename ProxiFyreService"#,
|
||||
)
|
||||
.with_service_path(
|
||||
"ProxiFyre",
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
)
|
||||
.with_known_binary(executable.to_str().expect("fixture path"));
|
||||
|
||||
assert_eq!(
|
||||
inventory_proxyfier_with_host(&host).classification(),
|
||||
ComponentClassification::ManagedLegacy,
|
||||
"Task 5 discovery/Start/Stop classification stays unchanged"
|
||||
);
|
||||
assert!(has_additional_matching_legacy_proxifyre_service_with_host(
|
||||
&host, root
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_present_alias_service_makes_the_strict_service_set_ambiguous() {
|
||||
let root = Path::new(r"C:\Tools\ProxiFyre");
|
||||
for host in [
|
||||
MockHost::new().with_service("ProxiFyre"),
|
||||
MockHost::new().with_service_path("ProxiFyre", r#""C:\Foreign\ProxiFyre.exe" --service"#),
|
||||
] {
|
||||
assert!(has_additional_matching_legacy_proxifyre_service_with_host(
|
||||
&host, root
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_proxyfier_returns_install_action_status() {
|
||||
let component = proxyfier_component_from_detection(None);
|
||||
@@ -121,11 +230,15 @@ fn ignores_known_service_name_when_path_points_to_foreign_binary() {
|
||||
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);
|
||||
assert!(detect_proxyfier_install_with_host(&host).is_none());
|
||||
let inventory = inventory_proxyfier_with_host(&host);
|
||||
let candidate = inventory.selected_candidate().expect("foreign collision");
|
||||
assert_eq!(candidate.classification, ComponentClassification::Foreign);
|
||||
assert_eq!(candidate.issues[0].code, OWNERSHIP_MISMATCH);
|
||||
let component = proxyfier_component_from_inventory(&inventory);
|
||||
assert_eq!(component.state, ComponentState::Error);
|
||||
assert!(!component.running);
|
||||
assert!(component.actions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -135,18 +248,32 @@ fn ignores_known_service_name_without_path_metadata() {
|
||||
.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);
|
||||
assert!(detect_proxyfier_install_with_host(&host).is_none());
|
||||
let inventory = inventory_proxyfier_with_host(&host);
|
||||
assert_eq!(inventory.classification(), ComponentClassification::Foreign);
|
||||
assert_eq!(
|
||||
inventory.selected_candidate().unwrap().issues[0].code,
|
||||
OWNERSHIP_MISMATCH
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
let host = MockHost::new()
|
||||
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe")
|
||||
.with_service("ProxyWardenSingBox");
|
||||
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe")
|
||||
.with_text(
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.xml",
|
||||
winsw_xml(),
|
||||
)
|
||||
.with_service_path(
|
||||
"ProxyWardenSingBox",
|
||||
r#""C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe""#,
|
||||
)
|
||||
.with_version(
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe",
|
||||
"1.11.0.0",
|
||||
);
|
||||
|
||||
let detected =
|
||||
detect_singbox_install_with_host(&host).expect("existing sing-box should be detected");
|
||||
@@ -157,6 +284,7 @@ fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
);
|
||||
assert_eq!(detected.service_name, "ProxyWardenSingBox");
|
||||
assert!(detected.running);
|
||||
assert_eq!(detected.version, Some("1.11.0.0".to_string()));
|
||||
|
||||
let component = singbox_component_from_detection(Some(&detected));
|
||||
assert_eq!(component.state, ComponentState::Running);
|
||||
@@ -170,30 +298,99 @@ fn detects_running_local_singbox_from_default_install_root_and_service() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_stopped_local_singbox_from_env_override() {
|
||||
fn current_singbox_with_foreign_same_name_service_is_ownership_mismatch() {
|
||||
let host = MockHost::new()
|
||||
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe")
|
||||
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe")
|
||||
.with_text(
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.xml",
|
||||
winsw_xml(),
|
||||
)
|
||||
.with_service_path(
|
||||
"ProxyWardenSingBox",
|
||||
r#""C:\Foreign\ProxyWardenSingBox.exe""#,
|
||||
);
|
||||
|
||||
let inventory = inventory_singbox_with_host(&host);
|
||||
assert_eq!(inventory.classification(), ComponentClassification::Foreign);
|
||||
assert_eq!(
|
||||
inventory.selected_candidate().unwrap().issues[0].code,
|
||||
OWNERSHIP_MISMATCH
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winsw_identity_cannot_be_spoofed_by_comments_or_unrelated_nodes() {
|
||||
let spoofed_xml = r#"<service>
|
||||
<!-- <id>ProxyWardenSingBox</id> -->
|
||||
<!-- <executable>%BASE%\sing-box.exe</executable> -->
|
||||
<metadata><arguments>run -c "%BASE%\config.json"</arguments></metadata>
|
||||
<id>ForeignService</id>
|
||||
<executable>C:\Foreign\sing-box.exe</executable>
|
||||
<arguments>run -c "C:\Foreign\config.json"</arguments>
|
||||
</service>"#;
|
||||
let host = MockHost::new()
|
||||
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe")
|
||||
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe")
|
||||
.with_text(
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.xml",
|
||||
spoofed_xml,
|
||||
)
|
||||
.with_service_path(
|
||||
"ProxyWardenSingBox",
|
||||
r#""C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe""#,
|
||||
);
|
||||
|
||||
let inventory = inventory_singbox_with_host(&host);
|
||||
assert_eq!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::Incomplete
|
||||
);
|
||||
assert!(detect_singbox_install_with_host(&host).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winsw_identity_rejects_duplicate_dtd_cdata_second_root_and_oversized_xml() {
|
||||
let oversized = format!(
|
||||
"<service><id>ProxyWardenSingBox</id><executable>%BASE%\\sing-box.exe</executable><arguments>run -c \"%BASE%\\config.json\"</arguments><description>{}</description></service>",
|
||||
"x".repeat(65 * 1024)
|
||||
);
|
||||
let invalid_xml = vec![
|
||||
r#"<service><id>ProxyWardenSingBox</id><id>ProxyWardenSingBox</id><executable>%BASE%\sing-box.exe</executable><arguments>run -c "%BASE%\config.json"</arguments></service>"#.to_string(),
|
||||
r#"<!DOCTYPE service [<!ENTITY owned "ProxyWardenSingBox">]><service><id>&owned;</id><executable>%BASE%\sing-box.exe</executable><arguments>run -c "%BASE%\config.json"</arguments></service>"#.to_string(),
|
||||
r#"<service><id><![CDATA[ProxyWardenSingBox]]></id><executable>%BASE%\sing-box.exe</executable><arguments>run -c "%BASE%\config.json"</arguments></service>"#.to_string(),
|
||||
format!("{}<service></service>", winsw_xml()),
|
||||
oversized,
|
||||
];
|
||||
|
||||
for xml in invalid_xml {
|
||||
let host = current_singbox_host_with_xml(&xml);
|
||||
assert!(
|
||||
detect_singbox_install_with_host(&host).is_none(),
|
||||
"unsafe WinSW XML was accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winsw_identity_accepts_xml_declaration_bom_and_current_extra_nodes() {
|
||||
let xml = "\u{feff}<?xml version=\"1.0\" encoding=\"utf-8\"?><service><id>ProxyWardenSingBox</id><name>ProxyWarden Local sing-box</name><executable>%BASE%\\sing-box.exe</executable><arguments>run -c \"%BASE%\\config.json\"</arguments><log mode=\"roll-by-size\"><keepFiles>4</keepFiles></log><onfailure action=\"restart\" /></service>".to_string();
|
||||
let host = current_singbox_host_with_xml(&xml);
|
||||
|
||||
assert!(detect_singbox_install_with_host(&host).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portable_singbox_env_override_remains_foreign() {
|
||||
let host = MockHost::new()
|
||||
.with_env("PROXYWARDEN_SINGBOX_ROOT", r"D:\Portable\sing-box")
|
||||
.with_path(r"D:\Portable\sing-box\sing-box.exe");
|
||||
|
||||
let detected = detect_singbox_install_with_host(&host).expect("env override should be checked");
|
||||
let component = singbox_component_from_detection(Some(&detected));
|
||||
|
||||
assert!(detect_singbox_install_with_host(&host).is_none());
|
||||
assert_eq!(
|
||||
detected.executable_path,
|
||||
PathBuf::from(r"D:\Portable\sing-box\sing-box.exe")
|
||||
inventory_singbox_with_host(&host).classification(),
|
||||
ComponentClassification::Foreign
|
||||
);
|
||||
assert_eq!(component.state, ComponentState::Stopped);
|
||||
assert!(component.installed);
|
||||
assert!(!component.running);
|
||||
assert_eq!(
|
||||
component.service_name,
|
||||
Some("ProxyWardenSingBox".to_string())
|
||||
);
|
||||
assert_eq!(component.service_status, Some("stopped".to_string()));
|
||||
assert!(component
|
||||
.problems
|
||||
.iter()
|
||||
.any(|problem| problem.contains("остановлена")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -207,6 +404,153 @@ fn missing_local_singbox_returns_optional_install_action_status() {
|
||||
assert!(component.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_service_pathname_without_accepting_malformed_quotes() {
|
||||
assert_eq!(
|
||||
service_executable_from_path_name(
|
||||
r#""C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe" install"#,
|
||||
),
|
||||
Some(PathBuf::from(
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
service_executable_from_path_name(r"C:\Tools\ProxiFyre\ProxiFyre.exe --service"),
|
||||
Some(PathBuf::from(r"C:\Tools\ProxiFyre\ProxiFyre.exe"))
|
||||
);
|
||||
assert!(service_executable_from_path_name(r#""C:\Broken\ProxiFyre.exe --service"#).is_none());
|
||||
assert!(service_executable_from_path_name(" ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_proxifyre_manifest_matches_all_ten_files_and_nothing_less() {
|
||||
let observed = LEGACY_PROXIFYRE_2_2_1_MANIFEST
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|file| LegacyPackageFileIdentity {
|
||||
relative_path: PathBuf::from(file.relative_path.to_ascii_uppercase()),
|
||||
size: file.size,
|
||||
sha256: file.sha256.to_ascii_uppercase(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert!(matches_legacy_proxifyre_2_2_1_manifest(&observed));
|
||||
|
||||
let mut missing = observed.clone();
|
||||
missing.pop();
|
||||
assert!(!matches_legacy_proxifyre_2_2_1_manifest(&missing));
|
||||
|
||||
let mut wrong_size = observed.clone();
|
||||
wrong_size[0].size += 1;
|
||||
assert!(!matches_legacy_proxifyre_2_2_1_manifest(&wrong_size));
|
||||
|
||||
let mut wrong_hash = observed.clone();
|
||||
wrong_hash[0].sha256 = "0".repeat(64);
|
||||
assert!(!matches_legacy_proxifyre_2_2_1_manifest(&wrong_hash));
|
||||
|
||||
let mut extra = observed.clone();
|
||||
extra.push(LegacyPackageFileIdentity {
|
||||
relative_path: PathBuf::from("unexpected.dll"),
|
||||
size: 1,
|
||||
sha256: "0".repeat(64),
|
||||
});
|
||||
assert!(!matches_legacy_proxifyre_2_2_1_manifest(&extra));
|
||||
|
||||
let mut duplicate = observed;
|
||||
duplicate[0] = duplicate[1].clone();
|
||||
assert!(!matches_legacy_proxifyre_2_2_1_manifest(&duplicate));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn reads_windows_pe_file_version_without_executing_binary() {
|
||||
let windows_dir = std::env::var("WINDIR").expect("WINDIR on Windows");
|
||||
let notepad = PathBuf::from(windows_dir)
|
||||
.join("System32")
|
||||
.join("notepad.exe");
|
||||
let version = SystemProxyfierDetectionHost
|
||||
.file_version(¬epad)
|
||||
.expect("notepad PE version");
|
||||
|
||||
assert_eq!(version.split('.').count(), 4);
|
||||
assert!(version
|
||||
.split('.')
|
||||
.all(|segment| segment.parse::<u32>().is_ok()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_component_detection_has_only_native_windows_owners() {
|
||||
let source = include_str!("../src/component_detection.rs");
|
||||
let source_lower = source.to_ascii_lowercase();
|
||||
|
||||
for forbidden in [
|
||||
"command_no_window(",
|
||||
"get-process",
|
||||
"get-service",
|
||||
"get-ciminstance",
|
||||
"\"powershell\"",
|
||||
"std::process::command",
|
||||
"extern \"system\"",
|
||||
"#[link(",
|
||||
] {
|
||||
assert!(
|
||||
!source_lower.contains(forbidden),
|
||||
"production detection still contains shell boundary: {forbidden}"
|
||||
);
|
||||
}
|
||||
for native_owner in [
|
||||
"CreateToolhelp32Snapshot",
|
||||
"OpenSCManagerW",
|
||||
"QueryServiceStatusEx",
|
||||
"QueryServiceConfigW",
|
||||
"winreg::",
|
||||
] {
|
||||
assert!(
|
||||
source.contains(native_owner),
|
||||
"native detection owner is missing: {native_owner}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn native_process_inventory_finds_the_running_test_binary() {
|
||||
let executable_name = std::env::current_exe()
|
||||
.expect("current test executable")
|
||||
.file_name()
|
||||
.expect("current test executable file name")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
assert!(SystemProxyfierDetectionHost.process_running(&executable_name));
|
||||
assert!(SystemProxyfierDetectionHost.process_running(&executable_name.to_ascii_uppercase()));
|
||||
assert!(SystemProxyfierDetectionHost.process_running(executable_name.trim_end_matches(".exe")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn native_service_inventory_reads_status_and_path_from_scm() {
|
||||
let service = SystemProxyfierDetectionHost
|
||||
.service_info("EventLog")
|
||||
.expect("Windows EventLog service should be queryable without elevation");
|
||||
|
||||
assert_eq!(service.name, "EventLog");
|
||||
assert!(matches!(
|
||||
service.status.as_str(),
|
||||
"stopped"
|
||||
| "start pending"
|
||||
| "stop pending"
|
||||
| "running"
|
||||
| "continue pending"
|
||||
| "pause pending"
|
||||
| "paused"
|
||||
| "unknown"
|
||||
));
|
||||
assert!(service
|
||||
.path_name
|
||||
.as_deref()
|
||||
.is_some_and(|path| !path.trim().is_empty()));
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MockHost {
|
||||
env: HashMap<String, String>,
|
||||
@@ -214,6 +558,9 @@ struct MockHost {
|
||||
processes: HashSet<String>,
|
||||
services: HashMap<String, String>,
|
||||
service_paths: HashMap<String, String>,
|
||||
texts: HashMap<String, String>,
|
||||
known_binaries: HashSet<String>,
|
||||
versions: HashMap<String, String>,
|
||||
registry: Vec<RegistryInstallEntry>,
|
||||
}
|
||||
|
||||
@@ -267,6 +614,24 @@ impl MockHost {
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
fn with_text(mut self, path: &str, contents: &str) -> Self {
|
||||
self.paths.insert(normalize_path(path));
|
||||
self.texts
|
||||
.insert(normalize_path(path), contents.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
fn with_known_binary(mut self, path: &str) -> Self {
|
||||
self.known_binaries.insert(normalize_path(path));
|
||||
self
|
||||
}
|
||||
|
||||
fn with_version(mut self, path: &str, version: &str) -> Self {
|
||||
self.versions
|
||||
.insert(normalize_path(path), version.to_string());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ProxyfierDetectionHost for MockHost {
|
||||
@@ -306,8 +671,57 @@ impl ProxyfierDetectionHost for MockHost {
|
||||
fn registry_install_entries(&self) -> Vec<RegistryInstallEntry> {
|
||||
self.registry.clone()
|
||||
}
|
||||
|
||||
fn read_text(&self, path: &Path) -> Option<String> {
|
||||
self.texts
|
||||
.get(&normalize_path(&path.display().to_string()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn file_version(&self, path: &Path) -> Option<String> {
|
||||
self.versions
|
||||
.get(&normalize_path(&path.display().to_string()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn binary_identity(
|
||||
&self,
|
||||
_component_id: &proxywarden_lib::models::ComponentId,
|
||||
path: &Path,
|
||||
) -> BinaryIdentityEvidence {
|
||||
if self
|
||||
.known_binaries
|
||||
.contains(&normalize_path(&path.display().to_string()))
|
||||
{
|
||||
BinaryIdentityEvidence::KnownPackage
|
||||
} else {
|
||||
BinaryIdentityEvidence::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
path.replace('/', "\\").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn winsw_xml() -> &'static str {
|
||||
r#"<service>
|
||||
<id>ProxyWardenSingBox</id>
|
||||
<executable>%BASE%\sing-box.exe</executable>
|
||||
<arguments>run -c "%BASE%\config.json"</arguments>
|
||||
</service>"#
|
||||
}
|
||||
|
||||
fn current_singbox_host_with_xml(xml: &str) -> MockHost {
|
||||
MockHost::new()
|
||||
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\sing-box.exe")
|
||||
.with_path(r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe")
|
||||
.with_text(
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.xml",
|
||||
xml,
|
||||
)
|
||||
.with_service_path(
|
||||
"ProxyWardenSingBox",
|
||||
r#""C:\Program Files\ProxyWarden\components\sing-box\ProxyWardenSingBox.exe""#,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
use proxywarden_lib::component_detection::{
|
||||
proxyfier_component_from_inventory, singbox_component_from_inventory,
|
||||
};
|
||||
use proxywarden_lib::component_inventory::{
|
||||
authorize_component_action, classify_component_candidates,
|
||||
component_inventory_fingerprint_for_cutover, legacy_proxifyre_topshelf_path_matches,
|
||||
prove_legacy_cutover, BinaryIdentityEvidence, CandidateRole, ComponentCandidateProbe,
|
||||
ComponentClassification, InventoryAction, InventoryIssue, LegacyCutoverEvidence,
|
||||
LegacyCutoverProof, LegacyProxifyreScmProfile, MarkerEvidence, ServiceEvidence,
|
||||
AMBIGUOUS_LEGACY, MANUAL_MIGRATION_REQUIRED, OWNERSHIP_MISMATCH,
|
||||
};
|
||||
use proxywarden_lib::component_status::resolve_component_statuses_with_inventories;
|
||||
use proxywarden_lib::models::{ComponentId, ComponentState};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn managed_current_requires_marker_files_and_exact_service_path() {
|
||||
let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre");
|
||||
let inventory = classify_component_candidates(
|
||||
ComponentId::Proxyfier,
|
||||
vec![probe(
|
||||
ComponentId::Proxyfier,
|
||||
CandidateRole::Current,
|
||||
&root,
|
||||
true,
|
||||
MarkerEvidence::Valid,
|
||||
BinaryIdentityEvidence::Unknown,
|
||||
Some(service(&root.join("ProxiFyre.exe"), true)),
|
||||
)],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::ManagedCurrent
|
||||
);
|
||||
assert_eq!(
|
||||
inventory
|
||||
.selected_candidate()
|
||||
.expect("selected current")
|
||||
.binary_version,
|
||||
Some("2.4.0.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_service_name_with_foreign_path_is_ownership_mismatch() {
|
||||
let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre");
|
||||
let inventory = classify_component_candidates(
|
||||
ComponentId::Proxyfier,
|
||||
vec![probe(
|
||||
ComponentId::Proxyfier,
|
||||
CandidateRole::Current,
|
||||
&root,
|
||||
true,
|
||||
MarkerEvidence::Valid,
|
||||
BinaryIdentityEvidence::KnownPackage,
|
||||
Some(service(
|
||||
PathBuf::from(r"C:\Foreign\ProxiFyre.exe").as_path(),
|
||||
false,
|
||||
)),
|
||||
)],
|
||||
);
|
||||
|
||||
let candidate = inventory
|
||||
.selected_candidate()
|
||||
.expect("foreign current candidate");
|
||||
assert_eq!(candidate.classification, ComponentClassification::Foreign);
|
||||
assert_eq!(candidate.issues[0].code, OWNERSHIP_MISMATCH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tools_proxifyre_is_legacy_and_never_current() {
|
||||
let root = PathBuf::from(r"C:\Tools\ProxiFyre");
|
||||
let inventory = classify_component_candidates(
|
||||
ComponentId::Proxyfier,
|
||||
vec![probe(
|
||||
ComponentId::Proxyfier,
|
||||
CandidateRole::Legacy,
|
||||
&root,
|
||||
false,
|
||||
MarkerEvidence::NotRequired,
|
||||
BinaryIdentityEvidence::KnownPackage,
|
||||
Some(service(&root.join("ProxiFyre.exe"), true)),
|
||||
)],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::ManagedLegacy
|
||||
);
|
||||
let component = proxyfier_component_from_inventory(&inventory);
|
||||
assert_eq!(component.actions, vec!["Перенести ProxiFyre"]);
|
||||
assert!(component
|
||||
.problems
|
||||
.iter()
|
||||
.any(|problem| problem.contains("явного переноса")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_singbox_root_stays_foreign_without_complete_identity() {
|
||||
let root = PathBuf::from(r"C:\Program Files\sing-box");
|
||||
let mut candidate = probe(
|
||||
ComponentId::Singbox,
|
||||
CandidateRole::ForeignByDefault,
|
||||
&root,
|
||||
false,
|
||||
MarkerEvidence::NotRequired,
|
||||
BinaryIdentityEvidence::Unknown,
|
||||
Some(service(&root.join("ProxyWardenSingBox.exe"), true)),
|
||||
);
|
||||
candidate.legacy_identity_complete = false;
|
||||
let inventory = classify_component_candidates(ComponentId::Singbox, vec![candidate]);
|
||||
|
||||
assert_eq!(inventory.classification(), ComponentClassification::Foreign);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_root_without_required_marker_is_incomplete() {
|
||||
let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre");
|
||||
let inventory = classify_component_candidates(
|
||||
ComponentId::Proxyfier,
|
||||
vec![probe(
|
||||
ComponentId::Proxyfier,
|
||||
CandidateRole::Current,
|
||||
&root,
|
||||
true,
|
||||
MarkerEvidence::Missing,
|
||||
BinaryIdentityEvidence::KnownPackage,
|
||||
Some(service(&root.join("ProxiFyre.exe"), true)),
|
||||
)],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::Incomplete
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_status_preserves_foreign_and_incomplete_inventory_errors() {
|
||||
let proxyfier_root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre");
|
||||
let foreign_proxyfier = classify_component_candidates(
|
||||
ComponentId::Proxyfier,
|
||||
vec![probe(
|
||||
ComponentId::Proxyfier,
|
||||
CandidateRole::Current,
|
||||
&proxyfier_root,
|
||||
true,
|
||||
MarkerEvidence::Valid,
|
||||
BinaryIdentityEvidence::KnownPackage,
|
||||
Some(service(
|
||||
PathBuf::from(r"C:\Foreign\ProxiFyre.exe").as_path(),
|
||||
false,
|
||||
)),
|
||||
)],
|
||||
);
|
||||
let singbox_root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box");
|
||||
let mut incomplete_singbox_probe = probe(
|
||||
ComponentId::Singbox,
|
||||
CandidateRole::Current,
|
||||
&singbox_root,
|
||||
false,
|
||||
MarkerEvidence::NotRequired,
|
||||
BinaryIdentityEvidence::KnownPackage,
|
||||
Some(service(&singbox_root.join("ProxyWardenSingBox.exe"), true)),
|
||||
);
|
||||
incomplete_singbox_probe
|
||||
.missing_files
|
||||
.push(singbox_root.join("ProxyWardenSingBox.xml"));
|
||||
let incomplete_singbox =
|
||||
classify_component_candidates(ComponentId::Singbox, vec![incomplete_singbox_probe]);
|
||||
|
||||
let statuses =
|
||||
resolve_component_statuses_with_inventories(&foreign_proxyfier, &incomplete_singbox);
|
||||
let proxyfier = statuses
|
||||
.iter()
|
||||
.find(|status| status.id == ComponentId::Proxyfier)
|
||||
.expect("ProxiFyre status");
|
||||
let singbox = statuses
|
||||
.iter()
|
||||
.find(|status| status.id == ComponentId::Singbox)
|
||||
.expect("sing-box status");
|
||||
|
||||
assert_eq!(proxyfier.state, ComponentState::Error);
|
||||
assert_eq!(singbox.state, ComponentState::Error);
|
||||
assert!(!proxyfier.problems.is_empty());
|
||||
assert!(!singbox.problems.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_candidate_wins_but_legacy_remains_visible() {
|
||||
let current_root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre");
|
||||
let legacy_root = PathBuf::from(r"C:\Tools\ProxiFyre");
|
||||
let inventory = classify_component_candidates(
|
||||
ComponentId::Proxyfier,
|
||||
vec![
|
||||
probe(
|
||||
ComponentId::Proxyfier,
|
||||
CandidateRole::Legacy,
|
||||
&legacy_root,
|
||||
false,
|
||||
MarkerEvidence::NotRequired,
|
||||
BinaryIdentityEvidence::KnownPackage,
|
||||
Some(service(&legacy_root.join("ProxiFyre.exe"), true)),
|
||||
),
|
||||
probe(
|
||||
ComponentId::Proxyfier,
|
||||
CandidateRole::Current,
|
||||
¤t_root,
|
||||
true,
|
||||
MarkerEvidence::Valid,
|
||||
BinaryIdentityEvidence::KnownPackage,
|
||||
Some(service(¤t_root.join("ProxiFyre.exe"), true)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(inventory.candidates.len(), 2);
|
||||
assert_eq!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::ManagedCurrent
|
||||
);
|
||||
assert_eq!(
|
||||
inventory
|
||||
.selected_candidate()
|
||||
.expect("selected current")
|
||||
.root,
|
||||
current_root
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_managed_legacy_candidates_block_selection() {
|
||||
let roots = [
|
||||
PathBuf::from(r"C:\Tools\ProxiFyre"),
|
||||
PathBuf::from(r"C:\Program Files\ProxiFyre"),
|
||||
];
|
||||
let probes = roots
|
||||
.iter()
|
||||
.map(|root| {
|
||||
probe(
|
||||
ComponentId::Proxyfier,
|
||||
CandidateRole::Legacy,
|
||||
root,
|
||||
false,
|
||||
MarkerEvidence::NotRequired,
|
||||
BinaryIdentityEvidence::KnownPackage,
|
||||
Some(service(&root.join("ProxiFyre.exe"), true)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let inventory = classify_component_candidates(ComponentId::Proxyfier, probes);
|
||||
|
||||
assert!(inventory.selected_candidate().is_none());
|
||||
assert_eq!(inventory.issues[0].code, AMBIGUOUS_LEGACY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparse_point_is_never_managed() {
|
||||
let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box");
|
||||
let mut candidate = probe(
|
||||
ComponentId::Singbox,
|
||||
CandidateRole::Current,
|
||||
&root,
|
||||
false,
|
||||
MarkerEvidence::NotRequired,
|
||||
BinaryIdentityEvidence::KnownPackage,
|
||||
Some(service(&root.join("ProxyWardenSingBox.exe"), true)),
|
||||
);
|
||||
candidate.has_reparse_point = true;
|
||||
let inventory = classify_component_candidates(ComponentId::Singbox, vec![candidate]);
|
||||
|
||||
assert_eq!(inventory.classification(), ComponentClassification::Foreign);
|
||||
assert_eq!(
|
||||
inventory.selected_candidate().unwrap().issues[0].code,
|
||||
OWNERSHIP_MISMATCH
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_frozen_proxifyre_identity_is_the_only_automatic_cutover() {
|
||||
let root = PathBuf::from(r"C:\Tools\ProxiFyre");
|
||||
let inventory = legacy_inventory(
|
||||
ComponentId::Proxyfier,
|
||||
&root,
|
||||
"ProxiFyreService",
|
||||
&topshelf_path(&root),
|
||||
"2.2.1.0",
|
||||
);
|
||||
|
||||
let proof = prove_legacy_cutover(&inventory, &exact_cutover_evidence())
|
||||
.expect("exact identity must produce an opaque proof");
|
||||
assert_eq!(proof.fingerprint().len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_evidence_never_substitutes_for_cutover_identity() {
|
||||
let auto_root = PathBuf::from(r"C:\Tools\ProxiFyre");
|
||||
let cases = [
|
||||
legacy_inventory(
|
||||
ComponentId::Proxyfier,
|
||||
Path::new(r"C:\Program Files\ProxiFyre"),
|
||||
"ProxiFyreService",
|
||||
&topshelf_path(Path::new(r"C:\Program Files\ProxiFyre")),
|
||||
"2.2.1.0",
|
||||
),
|
||||
legacy_inventory(
|
||||
ComponentId::Proxyfier,
|
||||
&auto_root,
|
||||
"ProxiFyre",
|
||||
&topshelf_path(&auto_root),
|
||||
"2.2.1.0",
|
||||
),
|
||||
legacy_inventory(
|
||||
ComponentId::Proxyfier,
|
||||
&auto_root,
|
||||
"ProxiFyreService",
|
||||
&format!(
|
||||
r#""{}" --service"#,
|
||||
auto_root.join("ProxiFyre.exe").display()
|
||||
),
|
||||
"2.2.1.0",
|
||||
),
|
||||
legacy_inventory(
|
||||
ComponentId::Proxyfier,
|
||||
&auto_root,
|
||||
"ProxiFyreService",
|
||||
&topshelf_path(&auto_root),
|
||||
"2.4.0.0",
|
||||
),
|
||||
];
|
||||
|
||||
for inventory in cases {
|
||||
assert_eq!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::ManagedLegacy
|
||||
);
|
||||
assert_manual_without_mutation(prove_legacy_cutover(&inventory, &exact_cutover_evidence()));
|
||||
}
|
||||
|
||||
let inventory = legacy_inventory(
|
||||
ComponentId::Proxyfier,
|
||||
&auto_root,
|
||||
"ProxiFyreService",
|
||||
&topshelf_path(&auto_root),
|
||||
"2.2.1.0",
|
||||
);
|
||||
let mut bad_manifest = exact_cutover_evidence();
|
||||
bad_manifest.proxifyre_manifest_matches = false;
|
||||
assert_manual_without_mutation(prove_legacy_cutover(&inventory, &bad_manifest));
|
||||
|
||||
let mut bad_snapshot_fingerprint = exact_cutover_evidence();
|
||||
bad_snapshot_fingerprint
|
||||
.proxifyre_scm_snapshot_fingerprint
|
||||
.clear();
|
||||
assert_manual_without_mutation(prove_legacy_cutover(&inventory, &bad_snapshot_fingerprint));
|
||||
|
||||
let mut bad_profile = exact_cutover_evidence();
|
||||
bad_profile
|
||||
.proxifyre_scm_profile
|
||||
.as_mut()
|
||||
.expect("profile")
|
||||
.delayed_auto_start = true;
|
||||
assert_manual_without_mutation(prove_legacy_cutover(&inventory, &bad_profile));
|
||||
|
||||
let mut extra_candidate = inventory.clone();
|
||||
extra_candidate
|
||||
.candidates
|
||||
.push(extra_candidate.candidates[0].clone());
|
||||
assert_manual_without_mutation(prove_legacy_cutover(
|
||||
&extra_candidate,
|
||||
&exact_cutover_evidence(),
|
||||
));
|
||||
|
||||
let mut alias_collision = exact_cutover_evidence();
|
||||
alias_collision.additional_matching_service = true;
|
||||
assert_manual_without_mutation(prove_legacy_cutover(&inventory, &alias_collision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_singbox_is_always_manual_and_has_zero_mutation_authority() {
|
||||
let root = PathBuf::from(r"C:\Program Files\ProxyWarden\sing-box");
|
||||
let inventory = legacy_inventory(
|
||||
ComponentId::Singbox,
|
||||
&root,
|
||||
"ProxyWardenSingBox",
|
||||
&format!(r#""{}""#, root.join("ProxyWardenSingBox.exe").display()),
|
||||
"1.13.19",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
inventory.classification(),
|
||||
ComponentClassification::ManagedLegacy
|
||||
);
|
||||
let component = singbox_component_from_inventory(&inventory);
|
||||
assert!(component.actions.is_empty());
|
||||
assert!(component
|
||||
.problems
|
||||
.iter()
|
||||
.any(|problem| problem.contains("ручного переноса")));
|
||||
assert_manual_without_mutation(prove_legacy_cutover(&inventory, &exact_cutover_evidence()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_inventory_authorization_never_grants_cutover() {
|
||||
let root = PathBuf::from(r"C:\Tools\ProxiFyre");
|
||||
let inventory = legacy_inventory(
|
||||
ComponentId::Proxyfier,
|
||||
&root,
|
||||
"ProxiFyreService",
|
||||
&topshelf_path(&root),
|
||||
"2.2.1.0",
|
||||
);
|
||||
|
||||
let error = authorize_component_action(&inventory, InventoryAction::Cutover)
|
||||
.expect_err("generic lifecycle authorization must not grant cutover");
|
||||
assert_eq!(error.code, "legacy_cutover_required");
|
||||
let mut current = inventory.clone();
|
||||
current.candidates[0].classification = ComponentClassification::ManagedCurrent;
|
||||
let missing =
|
||||
proxywarden_lib::component_inventory::ComponentInventory::missing(ComponentId::Proxyfier);
|
||||
for inventory in [¤t, &missing] {
|
||||
assert!(authorize_component_action(inventory, InventoryAction::Cutover).is_err());
|
||||
}
|
||||
prove_legacy_cutover(&inventory, &exact_cutover_evidence())
|
||||
.expect("strict gate remains the only proof constructor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_inventory_authorization_never_writes_legacy_runtime_config() {
|
||||
let root = PathBuf::from(r"C:\Tools\ProxiFyre");
|
||||
let inventory = legacy_inventory(
|
||||
ComponentId::Proxyfier,
|
||||
&root,
|
||||
"ProxiFyreService",
|
||||
&topshelf_path(&root),
|
||||
"2.2.1.0",
|
||||
);
|
||||
|
||||
for action in [
|
||||
InventoryAction::Apply,
|
||||
InventoryAction::Start,
|
||||
InventoryAction::Stop,
|
||||
] {
|
||||
let error = authorize_component_action(&inventory, action)
|
||||
.expect_err("legacy runtime actions require explicit cutover");
|
||||
assert_eq!(error.code, "legacy_cutover_required");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cutover_inventory_fingerprint_is_stable_and_binds_live_service_state() {
|
||||
let root = PathBuf::from(r"C:\Tools\ProxiFyre");
|
||||
let inventory = legacy_inventory(
|
||||
ComponentId::Proxyfier,
|
||||
&root,
|
||||
"ProxiFyreService",
|
||||
&topshelf_path(&root),
|
||||
"2.2.1.0",
|
||||
);
|
||||
let first = component_inventory_fingerprint_for_cutover(&inventory);
|
||||
assert_eq!(
|
||||
first,
|
||||
component_inventory_fingerprint_for_cutover(&inventory)
|
||||
);
|
||||
|
||||
let mut changed = inventory.clone();
|
||||
changed.candidates[0]
|
||||
.service
|
||||
.as_mut()
|
||||
.expect("service")
|
||||
.status = "running".to_string();
|
||||
assert_ne!(first, component_inventory_fingerprint_for_cutover(&changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_scm_profile_rejects_each_unsafe_or_unknown_field() {
|
||||
let mutations: [fn(&mut LegacyProxifyreScmProfile); 15] = [
|
||||
|profile| profile.service_type = 0x20,
|
||||
|profile| profile.start_type = 3,
|
||||
|profile| profile.error_control = 0,
|
||||
|profile| profile.account_name = "NetworkService".to_string(),
|
||||
|profile| profile.display_name = "ProxiFyre".to_string(),
|
||||
|profile| profile.description.clear(),
|
||||
|profile| profile.dependencies.push("Tcpip".to_string()),
|
||||
|profile| profile.load_order_group = Some("Network".to_string()),
|
||||
|profile| profile.has_failure_actions = true,
|
||||
|profile| profile.failure_actions_on_non_crash = true,
|
||||
|profile| profile.delayed_auto_start = true,
|
||||
|profile| profile.sid_type = 1,
|
||||
|profile| {
|
||||
profile
|
||||
.required_privileges
|
||||
.push("SeDebugPrivilege".to_string())
|
||||
},
|
||||
|profile| profile.has_triggers = true,
|
||||
|profile| profile.untrusted_mutation_rights = true,
|
||||
];
|
||||
|
||||
assert!(exact_scm_profile().matches_frozen_2_2_1_profile());
|
||||
for mutate in mutations {
|
||||
let mut profile = exact_scm_profile();
|
||||
mutate(&mut profile);
|
||||
assert!(!profile.matches_frozen_2_2_1_profile());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topshelf_cutover_path_is_token_exact_and_pair_order_independent() {
|
||||
let executable = Path::new(r"C:\Tools\ProxiFyre\ProxiFyre.exe");
|
||||
for path_name in [
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename "ProxiFyreService""#,
|
||||
r#"C:\Tools\ProxiFyre\ProxiFyre.exe -servicename ProxiFyreService -displayname "ProxiFyre Service""#,
|
||||
] {
|
||||
assert!(legacy_proxifyre_topshelf_path_matches(
|
||||
path_name, executable
|
||||
));
|
||||
}
|
||||
for path_name in [
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" --service"#,
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename ProxiFyreService --run"#,
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "Foreign" -servicename ProxiFyreService"#,
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename ProxiFyre"#,
|
||||
r#""C:\Tools\ProxiFyre\ProxiFyre.exe -displayname "ProxiFyre Service" -servicename ProxiFyreService"#,
|
||||
] {
|
||||
assert!(!legacy_proxifyre_topshelf_path_matches(
|
||||
path_name, executable
|
||||
));
|
||||
}
|
||||
assert!(!legacy_proxifyre_topshelf_path_matches(
|
||||
r#""C:\Program Files\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename ProxiFyreService"#,
|
||||
Path::new(r"C:\Program Files\ProxiFyre\ProxiFyre.exe"),
|
||||
));
|
||||
}
|
||||
|
||||
fn assert_manual_without_mutation(result: Result<LegacyCutoverProof, InventoryIssue>) {
|
||||
assert_eq!(
|
||||
result
|
||||
.expect_err("manual identity must not yield a proof")
|
||||
.code,
|
||||
MANUAL_MIGRATION_REQUIRED
|
||||
);
|
||||
}
|
||||
|
||||
fn exact_cutover_evidence() -> LegacyCutoverEvidence {
|
||||
LegacyCutoverEvidence {
|
||||
proxifyre_manifest_matches: true,
|
||||
proxifyre_scm_profile: Some(exact_scm_profile()),
|
||||
proxifyre_scm_snapshot_fingerprint: "9".repeat(64),
|
||||
additional_matching_service: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_scm_profile() -> LegacyProxifyreScmProfile {
|
||||
LegacyProxifyreScmProfile {
|
||||
service_type: 0x10,
|
||||
start_type: 2,
|
||||
error_control: 1,
|
||||
account_name: "LocalSystem".to_string(),
|
||||
display_name: "ProxiFyre Service".to_string(),
|
||||
description: "ProxiFyre - SOCKS5 ProxiFyre Service".to_string(),
|
||||
dependencies: Vec::new(),
|
||||
load_order_group: None,
|
||||
has_failure_actions: false,
|
||||
failure_actions_on_non_crash: false,
|
||||
delayed_auto_start: false,
|
||||
sid_type: 0,
|
||||
required_privileges: Vec::new(),
|
||||
has_triggers: false,
|
||||
untrusted_mutation_rights: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_inventory(
|
||||
component_id: ComponentId,
|
||||
root: &Path,
|
||||
service_name: &str,
|
||||
path_name: &str,
|
||||
version: &str,
|
||||
) -> proxywarden_lib::component_inventory::ComponentInventory {
|
||||
let executable_name = match component_id {
|
||||
ComponentId::Proxyfier => "ProxiFyre.exe",
|
||||
ComponentId::Singbox => "sing-box.exe",
|
||||
ComponentId::ControlApp => "ProxyWarden.exe",
|
||||
};
|
||||
let service_executable = match component_id {
|
||||
ComponentId::Singbox => root.join("ProxyWardenSingBox.exe"),
|
||||
ComponentId::Proxyfier | ComponentId::ControlApp => root.join(executable_name),
|
||||
};
|
||||
classify_component_candidates(
|
||||
component_id.clone(),
|
||||
vec![ComponentCandidateProbe {
|
||||
component_id,
|
||||
role: CandidateRole::Legacy,
|
||||
root: root.to_path_buf(),
|
||||
root_exists: true,
|
||||
has_reparse_point: false,
|
||||
executable_path: Some(root.join(executable_name)),
|
||||
missing_files: Vec::new(),
|
||||
marker: MarkerEvidence::NotRequired,
|
||||
marker_required: false,
|
||||
binary_identity: BinaryIdentityEvidence::KnownPackage,
|
||||
binary_version: Some(version.to_string()),
|
||||
service: Some(ServiceEvidence {
|
||||
name: service_name.to_string(),
|
||||
status: "stopped".to_string(),
|
||||
path_name: Some(path_name.to_string()),
|
||||
executable_path: Some(service_executable),
|
||||
path_matches_candidate: true,
|
||||
binary_version: Some(version.to_string()),
|
||||
}),
|
||||
service_required: true,
|
||||
legacy_identity_complete: true,
|
||||
}],
|
||||
)
|
||||
}
|
||||
|
||||
fn topshelf_path(root: &Path) -> String {
|
||||
format!(
|
||||
r#""{}" -displayname "ProxiFyre Service" -servicename "ProxiFyreService""#,
|
||||
root.join("ProxiFyre.exe").display()
|
||||
)
|
||||
}
|
||||
|
||||
fn probe(
|
||||
component_id: ComponentId,
|
||||
role: CandidateRole,
|
||||
root: &Path,
|
||||
marker_required: bool,
|
||||
marker: MarkerEvidence,
|
||||
binary_identity: BinaryIdentityEvidence,
|
||||
service: Option<ServiceEvidence>,
|
||||
) -> ComponentCandidateProbe {
|
||||
let executable_name = match component_id {
|
||||
ComponentId::Proxyfier => "ProxiFyre.exe",
|
||||
ComponentId::Singbox => "sing-box.exe",
|
||||
ComponentId::ControlApp => "ProxyWarden.exe",
|
||||
};
|
||||
ComponentCandidateProbe {
|
||||
component_id,
|
||||
role,
|
||||
root: root.to_path_buf(),
|
||||
root_exists: true,
|
||||
has_reparse_point: false,
|
||||
executable_path: Some(root.join(executable_name)),
|
||||
missing_files: Vec::new(),
|
||||
marker,
|
||||
marker_required,
|
||||
binary_identity,
|
||||
binary_version: Some("2.4.0.0".to_string()),
|
||||
service,
|
||||
service_required: true,
|
||||
legacy_identity_complete: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn service(executable: &std::path::Path, matches: bool) -> ServiceEvidence {
|
||||
ServiceEvidence {
|
||||
name: "ProxiFyreService".to_string(),
|
||||
status: "stopped".to_string(),
|
||||
path_name: Some(format!(r#""{}" --service"#, executable.display())),
|
||||
executable_path: Some(executable.to_path_buf()),
|
||||
path_matches_candidate: matches,
|
||||
binary_version: Some("2.4.0.0".to_string()),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,423 @@
|
||||
use proxywarden_lib::component_catalog::{ComponentId, ComponentPackage, UpdateTrustPolicy};
|
||||
use proxywarden_lib::component_packages::{
|
||||
ComponentPackageService, ComponentUpdateObservation, ComponentUpdatesState,
|
||||
GithubReleaseDigestProof, PackageCacheManifest, PackageSource, TrustedGithubReleaseObservation,
|
||||
COMPONENT_UPDATES_STATE_SCHEMA_VERSION, PACKAGE_CACHE_MANIFEST_FILENAME,
|
||||
PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION,
|
||||
};
|
||||
use proxywarden_lib::safe_fs::{ensure_no_reparse_ancestors, protect_path_for_owner_admin_system};
|
||||
use proxywarden_lib::storage::StoragePaths;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn offline_selection_uses_the_real_bundled_package_without_a_cache() {
|
||||
let packages = TestDirectory::new();
|
||||
let paths = StoragePaths::new(packages.path().join("missing-storage"));
|
||||
let service = ComponentPackageService::open(bundled_root(), &paths)
|
||||
.expect("open local component package service");
|
||||
|
||||
let selected = service
|
||||
.select_verified(ComponentId::Proxifyre)
|
||||
.expect("select bundled package offline");
|
||||
|
||||
assert_eq!(selected.source, PackageSource::Bundled);
|
||||
assert_eq!(selected.version, "2.4.0");
|
||||
assert_eq!(
|
||||
selected.asset_path,
|
||||
bundled_root()
|
||||
.join("proxifyre")
|
||||
.join("ProxiFyre-v2.4.0-x64-signed.zip")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_newer_cache_wins_with_numeric_version_ordering() {
|
||||
let packages = TestDirectory::new();
|
||||
let service = open_service(&packages);
|
||||
let component = sing_box_component(&service);
|
||||
let (expected, manifest) =
|
||||
write_verified_cache(&packages.packages_path(), &component, "1.100.0", |_| {});
|
||||
write_trusted_state(&packages.state_path(), &manifest);
|
||||
|
||||
let selected = service
|
||||
.select_verified(ComponentId::SingBox)
|
||||
.expect("select newest verified cache");
|
||||
|
||||
assert_eq!(selected.source, PackageSource::Cache);
|
||||
assert_eq!(selected.version, "1.100.0");
|
||||
assert_eq!(selected.package_root, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_verified_cache_does_not_replace_the_bundle() {
|
||||
let packages = TestDirectory::new();
|
||||
let service = open_service(&packages);
|
||||
let component = sing_box_component(&service);
|
||||
let (_, manifest) =
|
||||
write_verified_cache(&packages.packages_path(), &component, "1.13.19", |_| {});
|
||||
write_trusted_state(&packages.state_path(), &manifest);
|
||||
|
||||
assert_bundled(&service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_verified_cache_does_not_replace_the_bundle() {
|
||||
let packages = TestDirectory::new();
|
||||
let service = open_service(&packages);
|
||||
let component = sing_box_component(&service);
|
||||
let (_, manifest) =
|
||||
write_verified_cache(&packages.packages_path(), &component, "1.13.18", |_| {});
|
||||
write_trusted_state(&packages.state_path(), &manifest);
|
||||
|
||||
assert_bundled(&service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_cache_does_not_break_bundled_fallback() {
|
||||
let packages = TestDirectory::new();
|
||||
let service = open_service(&packages);
|
||||
let component = sing_box_component(&service);
|
||||
let (version_root, manifest) =
|
||||
write_verified_cache(&packages.packages_path(), &component, "1.14.0", |_| {});
|
||||
write_trusted_state(&packages.state_path(), &manifest);
|
||||
fs::write(
|
||||
version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME),
|
||||
b"{not-json",
|
||||
)
|
||||
.expect("write corrupt manifest");
|
||||
|
||||
assert_bundled(&service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_from_the_wrong_repository_is_rejected() {
|
||||
let packages = TestDirectory::new();
|
||||
let service = open_service(&packages);
|
||||
let component = sing_box_component(&service);
|
||||
let (_, manifest) = write_verified_cache(
|
||||
&packages.packages_path(),
|
||||
&component,
|
||||
"1.14.0",
|
||||
|manifest| {
|
||||
manifest.independent_proof.repository = "attacker/sing-box".to_string();
|
||||
},
|
||||
);
|
||||
write_trusted_state(&packages.state_path(), &manifest);
|
||||
|
||||
assert_bundled(&service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handwritten_far_future_cache_without_trusted_state_is_rejected() {
|
||||
let packages = TestDirectory::new();
|
||||
let service = open_service(&packages);
|
||||
let component = sing_box_component(&service);
|
||||
write_verified_cache(&packages.packages_path(), &component, "999.0.0", |_| {});
|
||||
|
||||
assert_bundled(&service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_manifest_and_state_with_inherited_acl_are_rejected() {
|
||||
let packages = TestDirectory::new();
|
||||
let service = open_service(&packages);
|
||||
let component = sing_box_component(&service);
|
||||
let (_, manifest) = write_cache(
|
||||
&packages.packages_path(),
|
||||
&component,
|
||||
"1.14.0",
|
||||
|_| {},
|
||||
false,
|
||||
);
|
||||
write_state(&packages.state_path(), &manifest, false);
|
||||
|
||||
assert_bundled(&service);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_with_an_extra_file_is_rejected() {
|
||||
let packages = TestDirectory::new();
|
||||
let service = open_service(&packages);
|
||||
let component = sing_box_component(&service);
|
||||
let (version_root, manifest) =
|
||||
write_verified_cache(&packages.packages_path(), &component, "1.14.0", |_| {});
|
||||
write_trusted_state(&packages.state_path(), &manifest);
|
||||
fs::write(version_root.join("unexpected.txt"), b"not part of package")
|
||||
.expect("write unexpected cache file");
|
||||
|
||||
assert_bundled(&service);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn reparse_point_cache_root_is_rejected() {
|
||||
let workspace = TestDirectory::new();
|
||||
let target = workspace.path().join("junction-target");
|
||||
fs::create_dir_all(&target).expect("create junction target");
|
||||
let storage_paths = workspace.storage_paths();
|
||||
let service = ComponentPackageService::open(bundled_root(), &storage_paths)
|
||||
.expect("open service before creating junction");
|
||||
let component = sing_box_component(&service);
|
||||
let (_, manifest) = write_verified_cache(&target, &component, "1.14.0", |_| {});
|
||||
write_trusted_state(&storage_paths.component_updates_file, &manifest);
|
||||
|
||||
let junction = storage_paths.packages_dir.clone();
|
||||
let _junction_guard = create_junction(&junction, &target, workspace.path());
|
||||
|
||||
assert_bundled(&service);
|
||||
}
|
||||
|
||||
fn bundled_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("bundled")
|
||||
.join("components")
|
||||
}
|
||||
|
||||
fn open_service(packages: &TestDirectory) -> ComponentPackageService {
|
||||
ComponentPackageService::open(bundled_root(), &packages.storage_paths())
|
||||
.expect("production bundle must open")
|
||||
}
|
||||
|
||||
fn sing_box_component(service: &ComponentPackageService) -> ComponentPackage {
|
||||
service
|
||||
.catalog()
|
||||
.components
|
||||
.iter()
|
||||
.find(|component| component.id == ComponentId::SingBox)
|
||||
.expect("production sing-box component")
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn assert_bundled(service: &ComponentPackageService) {
|
||||
let selected = service
|
||||
.select_verified(ComponentId::SingBox)
|
||||
.expect("fall back to bundled package");
|
||||
assert_eq!(selected.source, PackageSource::Bundled);
|
||||
assert_eq!(selected.version, "1.13.19");
|
||||
}
|
||||
|
||||
fn cache_version_root(packages_root: &Path, version: &str) -> PathBuf {
|
||||
packages_root
|
||||
.join(ComponentId::SingBox.as_str())
|
||||
.join(version)
|
||||
}
|
||||
|
||||
fn write_verified_cache(
|
||||
packages_root: &Path,
|
||||
component: &ComponentPackage,
|
||||
version: &str,
|
||||
mutate: impl FnOnce(&mut PackageCacheManifest),
|
||||
) -> (PathBuf, PackageCacheManifest) {
|
||||
write_cache(packages_root, component, version, mutate, true)
|
||||
}
|
||||
|
||||
fn write_cache(
|
||||
packages_root: &Path,
|
||||
component: &ComponentPackage,
|
||||
version: &str,
|
||||
mutate: impl FnOnce(&mut PackageCacheManifest),
|
||||
protect: bool,
|
||||
) -> (PathBuf, PackageCacheManifest) {
|
||||
let version_root = cache_version_root(packages_root, version);
|
||||
fs::create_dir_all(&version_root).expect("create cache version directory");
|
||||
|
||||
let asset_name = format!("sing-box-{version}-windows-amd64.zip");
|
||||
let asset_bytes = format!("verified sing-box package {version}").into_bytes();
|
||||
let sha256 = format!("{:x}", Sha256::digest(&asset_bytes));
|
||||
let repository = match &component.update_trust_policy {
|
||||
UpdateTrustPolicy::GithubReleaseDigest {
|
||||
repository,
|
||||
authenticode_publishers,
|
||||
..
|
||||
} => {
|
||||
assert!(
|
||||
authenticode_publishers.is_none(),
|
||||
"sing-box cache must not claim Authenticode evidence"
|
||||
);
|
||||
repository.clone()
|
||||
}
|
||||
_ => panic!("sing-box must use GitHub release digest trust"),
|
||||
};
|
||||
let mut manifest = PackageCacheManifest {
|
||||
schema_version: PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION,
|
||||
component_id: component.id,
|
||||
version: version.to_string(),
|
||||
asset_name: asset_name.clone(),
|
||||
sha256: sha256.clone(),
|
||||
size: asset_bytes.len() as u64,
|
||||
independent_proof: GithubReleaseDigestProof {
|
||||
repository,
|
||||
release_id: 1,
|
||||
asset_id: 1,
|
||||
stable_tag: format!("v{version}"),
|
||||
asset_name: asset_name.clone(),
|
||||
size: asset_bytes.len() as u64,
|
||||
sha256_from_api: sha256,
|
||||
verified_signatures: Vec::new(),
|
||||
},
|
||||
};
|
||||
mutate(&mut manifest);
|
||||
|
||||
let asset_path = version_root.join(&asset_name);
|
||||
fs::write(&asset_path, asset_bytes).expect("write cached package asset");
|
||||
let manifest_path = version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME);
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
serde_json::to_vec_pretty(&manifest).expect("serialize cache manifest"),
|
||||
)
|
||||
.expect("write cache manifest");
|
||||
if protect {
|
||||
let component_root = packages_root.join(component.id.as_str());
|
||||
for path in [
|
||||
packages_root,
|
||||
component_root.as_path(),
|
||||
version_root.as_path(),
|
||||
asset_path.as_path(),
|
||||
manifest_path.as_path(),
|
||||
] {
|
||||
protect_path_for_owner_admin_system(path).expect("protect trusted cache path");
|
||||
}
|
||||
}
|
||||
(version_root, manifest)
|
||||
}
|
||||
|
||||
fn write_trusted_state(state_path: &Path, manifest: &PackageCacheManifest) {
|
||||
write_state(state_path, manifest, true);
|
||||
}
|
||||
|
||||
fn write_state(state_path: &Path, manifest: &PackageCacheManifest, protect: bool) {
|
||||
let proof = &manifest.independent_proof;
|
||||
let checked_at_unix = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock must be after Unix epoch")
|
||||
.as_secs();
|
||||
let state = ComponentUpdatesState {
|
||||
schema_version: COMPONENT_UPDATES_STATE_SCHEMA_VERSION,
|
||||
observations: vec![ComponentUpdateObservation {
|
||||
component_id: manifest.component_id,
|
||||
checked_at_unix,
|
||||
latest_known_version: manifest.version.clone(),
|
||||
trusted_releases: vec![TrustedGithubReleaseObservation {
|
||||
repository: proof.repository.clone(),
|
||||
release_id: proof.release_id,
|
||||
asset_id: proof.asset_id,
|
||||
stable_tag: proof.stable_tag.clone(),
|
||||
asset_name: proof.asset_name.clone(),
|
||||
size: proof.size,
|
||||
sha256_from_api: proof.sha256_from_api.clone(),
|
||||
}],
|
||||
}],
|
||||
};
|
||||
let state_parent = state_path.parent().expect("state path has parent");
|
||||
fs::create_dir_all(state_parent).expect("create state directory");
|
||||
fs::write(
|
||||
state_path,
|
||||
serde_json::to_vec_pretty(&state).expect("serialize trusted update state"),
|
||||
)
|
||||
.expect("write trusted update state");
|
||||
if protect {
|
||||
protect_path_for_owner_admin_system(state_parent).expect("protect state parent");
|
||||
protect_path_for_owner_admin_system(state_path).expect("protect trusted update state");
|
||||
}
|
||||
}
|
||||
|
||||
struct TestDirectory {
|
||||
path: PathBuf,
|
||||
target_root: PathBuf,
|
||||
}
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let target_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("target");
|
||||
fs::create_dir_all(&target_root).expect("create Cargo target directory");
|
||||
let target_root =
|
||||
fs::canonicalize(target_root).expect("canonicalize Cargo target directory");
|
||||
let path = target_root.join(format!("component-package-tests-{}", Uuid::new_v4()));
|
||||
fs::create_dir(&path).expect("create isolated component package test directory");
|
||||
Self { path, target_root }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn storage_paths(&self) -> StoragePaths {
|
||||
StoragePaths::new(&self.path)
|
||||
}
|
||||
|
||||
fn packages_path(&self) -> PathBuf {
|
||||
self.storage_paths().packages_dir
|
||||
}
|
||||
|
||||
fn state_path(&self) -> PathBuf {
|
||||
self.storage_paths().component_updates_file
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDirectory {
|
||||
fn drop(&mut self) {
|
||||
let has_exact_parent = self.path.parent() == Some(self.target_root.as_path());
|
||||
let has_test_name = self
|
||||
.path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.strip_prefix("component-package-tests-")
|
||||
.is_some_and(|id| Uuid::parse_str(id).is_ok())
|
||||
});
|
||||
if has_exact_parent
|
||||
&& has_test_name
|
||||
&& self.path.is_absolute()
|
||||
&& ensure_no_reparse_ancestors(&self.path).is_ok()
|
||||
{
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct JunctionGuard {
|
||||
path: PathBuf,
|
||||
expected_parent: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for JunctionGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.path.parent() == Some(self.expected_parent.as_path())
|
||||
&& self.path.file_name().is_some_and(|name| name == "packages")
|
||||
{
|
||||
let _ = fs::remove_dir(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn create_junction(path: &Path, target: &Path, expected_parent: &Path) -> JunctionGuard {
|
||||
assert_eq!(path.parent(), Some(expected_parent));
|
||||
assert_eq!(
|
||||
path.file_name().and_then(|name| name.to_str()),
|
||||
Some("packages")
|
||||
);
|
||||
let output = Command::new("cmd")
|
||||
.args(["/d", "/c", "mklink", "/J"])
|
||||
.arg(path)
|
||||
.arg(target)
|
||||
.output()
|
||||
.expect("run mklink for reparse-point fixture");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"mklink failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
JunctionGuard {
|
||||
path: path.to_path_buf(),
|
||||
expected_parent: expected_parent.to_path_buf(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use proxywarden_lib::{
|
||||
configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction},
|
||||
storage::JsonStorage,
|
||||
};
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
struct Fixture {
|
||||
root: PathBuf,
|
||||
storage: JsonStorage,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!("pw-transaction-{}", uuid::Uuid::new_v4()));
|
||||
let storage = JsonStorage::new(&root);
|
||||
storage.write_profiles(&[]).unwrap();
|
||||
storage.write_targets(&[]).unwrap();
|
||||
Self { root, storage }
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupted_commit_restores_primary_and_backup_before_next_read() {
|
||||
let fixture = Fixture::new();
|
||||
let path = &fixture.storage.paths().profiles_file;
|
||||
let before = fs::read(path).unwrap();
|
||||
let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap();
|
||||
fixture.storage.write_profiles(&[]).unwrap();
|
||||
fs::write(path, b"half-written").unwrap();
|
||||
transaction.abort().unwrap();
|
||||
assert_eq!(fs::read(path).unwrap(), before);
|
||||
assert!(!proxywarden_lib::safe_fs::backup_path(path).exists());
|
||||
let _guard = read_guard(&fixture.storage).unwrap();
|
||||
assert!(fixture.storage.read_profiles().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_lock_rejects_second_writer_and_reader() {
|
||||
let fixture = Fixture::new();
|
||||
let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap();
|
||||
assert!(ConfigurationTransaction::begin(&fixture.storage, None).is_err());
|
||||
assert!(read_guard(&fixture.storage).is_err());
|
||||
transaction.commit().unwrap();
|
||||
assert!(read_guard(&fixture.storage).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_revision_rejects_delayed_result_even_when_values_are_identical() {
|
||||
let fixture = Fixture::new();
|
||||
let revision = {
|
||||
let _guard = read_guard(&fixture.storage).unwrap();
|
||||
revision_locked(&fixture.storage).unwrap()
|
||||
};
|
||||
ConfigurationTransaction::begin(&fixture.storage, Some(&revision))
|
||||
.unwrap()
|
||||
.commit()
|
||||
.unwrap();
|
||||
assert!(ConfigurationTransaction::begin(&fixture.storage, Some(&revision)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_snapshot_blocks_all_restoration_and_next_writer() {
|
||||
let fixture = Fixture::new();
|
||||
let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap();
|
||||
let path = &fixture.storage.paths().profiles_file;
|
||||
fs::write(path, b"new-state").unwrap();
|
||||
fs::write(
|
||||
fixture
|
||||
.storage
|
||||
.paths()
|
||||
.migrations_dir
|
||||
.join("configuration-before-2.json"),
|
||||
b"damaged",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(transaction.abort().is_err());
|
||||
assert_eq!(
|
||||
fs::read(path).unwrap(),
|
||||
b"new-state",
|
||||
"validate all snapshots before restoring any"
|
||||
);
|
||||
assert!(read_guard(&fixture.storage).is_err());
|
||||
assert!(ConfigurationTransaction::begin(&fixture.storage, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_commit_removes_sensitive_fixed_snapshots() {
|
||||
let fixture = Fixture::new();
|
||||
ConfigurationTransaction::begin(&fixture.storage, None)
|
||||
.unwrap()
|
||||
.commit()
|
||||
.unwrap();
|
||||
for item in fs::read_dir(&fixture.storage.paths().migrations_dir).unwrap() {
|
||||
let name = item.unwrap().file_name().to_string_lossy().to_string();
|
||||
assert!(
|
||||
!name.starts_with("configuration-before-") && !name.starts_with("configuration-commit")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transaction_child() {
|
||||
let Some(root) = std::env::var_os("PW_TEST_TRANSACTION_ROOT") else {
|
||||
return;
|
||||
};
|
||||
let storage = JsonStorage::new(PathBuf::from(root));
|
||||
let Ok(_transaction) = ConfigurationTransaction::begin(&storage, None) else {
|
||||
std::process::exit(2);
|
||||
};
|
||||
fs::write(&storage.paths().profiles_file, b"interrupted-child-write").unwrap();
|
||||
// Deliberately bypass Drop, as a terminated application does.
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_marker_survives_partial_snapshot_cleanup_without_rollback() {
|
||||
let fixture = Fixture::new();
|
||||
let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap();
|
||||
fs::write(&fixture.storage.paths().profiles_file, b"committed-state").unwrap();
|
||||
// Model death after publishing the terminal marker and removing one snapshot.
|
||||
let journal = fixture
|
||||
.storage
|
||||
.paths()
|
||||
.migrations_dir
|
||||
.join("configuration-commit.json");
|
||||
let mut intent: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&journal).unwrap()).unwrap();
|
||||
intent["committed"] = serde_json::Value::Bool(true);
|
||||
fs::write(&journal, serde_json::to_vec(&intent).unwrap()).unwrap();
|
||||
fs::remove_file(
|
||||
fixture
|
||||
.storage
|
||||
.paths()
|
||||
.migrations_dir
|
||||
.join("configuration-before-0.json"),
|
||||
)
|
||||
.unwrap();
|
||||
drop(transaction);
|
||||
let _guard = read_guard(&fixture.storage).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.storage.paths().profiles_file).unwrap(),
|
||||
b"committed-state"
|
||||
);
|
||||
assert!(!journal.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_death_is_recovered_before_normal_read_and_lock_excludes_other_processes() {
|
||||
let fixture = Fixture::new();
|
||||
let before = fs::read(&fixture.storage.paths().profiles_file).unwrap();
|
||||
let launch = || {
|
||||
std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "transaction_child"])
|
||||
.env("PW_TEST_TRANSACTION_ROOT", &fixture.root)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.unwrap()
|
||||
};
|
||||
let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap();
|
||||
assert_eq!(launch().code(), Some(2));
|
||||
transaction.abort().unwrap();
|
||||
assert!(launch().success());
|
||||
assert_eq!(
|
||||
fs::read(&fixture.storage.paths().profiles_file).unwrap(),
|
||||
b"interrupted-child-write"
|
||||
);
|
||||
let _guard = read_guard(&fixture.storage).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.storage.paths().profiles_file).unwrap(),
|
||||
before
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,938 @@
|
||||
use proxywarden_lib::adapters::proxifyre::ProxiFyreAdapter;
|
||||
use proxywarden_lib::adapters::proxy_router::ProxyRouterRequest;
|
||||
use proxywarden_lib::component_detection::LEGACY_PROXIFYRE_2_2_1_MANIFEST;
|
||||
use proxywarden_lib::models::{
|
||||
ComponentStatus, LocalSingBoxConfig, Profile, ProfileItem, ProfileItemType, Protocol,
|
||||
ProxyProtocol, Target, TargetKind,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use url::Url;
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy")
|
||||
}
|
||||
|
||||
fn read_json(path: impl AsRef<Path>) -> Value {
|
||||
let path = path.as_ref();
|
||||
let contents = fs::read_to_string(path)
|
||||
.unwrap_or_else(|error| panic!("failed to read fixture {}: {error}", path.display()));
|
||||
serde_json::from_str(&contents)
|
||||
.unwrap_or_else(|error| panic!("invalid fixture JSON {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
fn contract() -> Value {
|
||||
read_json(fixture_root().join("contract.json"))
|
||||
}
|
||||
|
||||
fn fixture_case<'a>(contract: &'a Value, id: &str) -> &'a Value {
|
||||
contract["fixtures"]
|
||||
.as_array()
|
||||
.expect("fixtures array")
|
||||
.iter()
|
||||
.find(|case| case["id"] == id)
|
||||
.unwrap_or_else(|| panic!("missing fixture case {id}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_inventory_references_existing_parseable_json() {
|
||||
let contract = contract();
|
||||
assert_eq!(contract["schemaVersion"], 1);
|
||||
|
||||
let mut case_ids = BTreeSet::new();
|
||||
for case in contract["fixtures"].as_array().expect("fixtures array") {
|
||||
let id = case["id"].as_str().expect("fixture id");
|
||||
assert!(case_ids.insert(id), "duplicate fixture id {id}");
|
||||
|
||||
for relative in case["files"].as_array().expect("fixture files") {
|
||||
let relative = relative.as_str().expect("relative fixture path");
|
||||
let path = Path::new(relative);
|
||||
assert!(
|
||||
!path.is_absolute(),
|
||||
"fixture path must be relative: {relative}"
|
||||
);
|
||||
assert!(
|
||||
!path.components().any(|part| part == Component::ParentDir),
|
||||
"fixture path must not escape its root: {relative}"
|
||||
);
|
||||
|
||||
let full_path = fixture_root().join(path);
|
||||
assert!(
|
||||
full_path.is_file(),
|
||||
"missing fixture: {}",
|
||||
full_path.display()
|
||||
);
|
||||
read_json(full_path);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
case_ids,
|
||||
BTreeSet::from([
|
||||
"marker-formats",
|
||||
"pre-1.2-split",
|
||||
"proxifyre-generated",
|
||||
"proxifyre-real-sanitized",
|
||||
"proxifyre-unsupported"
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixture_values_are_sanitized_but_sensitive_key_names_are_preserved() {
|
||||
let root = fixture_root();
|
||||
let mut json_paths = Vec::new();
|
||||
collect_json_files(&root, &mut json_paths);
|
||||
assert!(!json_paths.is_empty(), "legacy fixture inventory is empty");
|
||||
|
||||
for path in json_paths {
|
||||
let value = read_json(&path);
|
||||
assert_sanitized(&value, "$", None)
|
||||
.unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
||||
}
|
||||
|
||||
let unsupported = read_json(root.join("proxifyre-unsupported/app-config.json"));
|
||||
let proxy = &unsupported["proxies"][0];
|
||||
assert!(proxy.get("username").is_some());
|
||||
assert!(proxy.get("password").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizer_rejects_non_redacted_sensitive_values_and_uri_userinfo() {
|
||||
for value in [
|
||||
serde_json::json!({"password": "not-a-secret-fixture"}),
|
||||
serde_json::json!({"password": "__REDACTED_REAL_SECRET__"}),
|
||||
serde_json::json!(
|
||||
"https://fixture-user:fixture-password@subscription.example.test/redacted"
|
||||
),
|
||||
serde_json::json!("fixture-user@proxy.example.test:1080"),
|
||||
] {
|
||||
let error = assert_sanitized(&value, "$", None).expect_err("value must be rejected");
|
||||
assert!(error.starts_with('$'));
|
||||
assert!(!error.contains("not-a-secret-fixture"));
|
||||
assert!(!error.contains("fixture-password"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_sanitized_sample_preserves_shape_and_records_provenance() {
|
||||
let contract = contract();
|
||||
let case = fixture_case(&contract, "proxifyre-real-sanitized");
|
||||
let provenance = &case["provenance"];
|
||||
let source_hash = provenance["sourceSha256"]
|
||||
.as_str()
|
||||
.expect("real sample source hash");
|
||||
assert_eq!(source_hash.len(), 64);
|
||||
assert!(source_hash
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_hexdigit()));
|
||||
assert!(provenance["source"]
|
||||
.as_str()
|
||||
.is_some_and(|source| source.contains("pre-1.2 local installation")));
|
||||
|
||||
let sample = read_json(fixture_root().join("proxifyre-real-sanitized/app-config.json"));
|
||||
assert_eq!(
|
||||
object_keys(&sample),
|
||||
BTreeSet::from(["bypassLan", "logLevel", "proxies"])
|
||||
);
|
||||
assert_eq!(sample["logLevel"], "Info");
|
||||
assert_eq!(sample["bypassLan"], true);
|
||||
assert_eq!(
|
||||
sample["proxies"].as_array().expect("sample proxies").len(),
|
||||
1
|
||||
);
|
||||
|
||||
let proxy = &sample["proxies"][0];
|
||||
assert_eq!(
|
||||
object_keys(proxy),
|
||||
BTreeSet::from(["appNames", "socks5ProxyEndpoint", "supportedProtocols"])
|
||||
);
|
||||
let app_names = proxy["appNames"].as_array().expect("sample app names");
|
||||
assert_eq!(app_names.len(), 8);
|
||||
assert_eq!(
|
||||
app_names
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter(|name| name.contains('\\'))
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
assert_eq!(
|
||||
proxy["supportedProtocols"],
|
||||
serde_json::json!(["TCP", "UDP"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_fixtures_freeze_weak_and_strong_schemas() {
|
||||
let root = fixture_root().join("markers");
|
||||
let weak = read_json(root.join("install-proxyfier.marker.json"));
|
||||
assert_eq!(
|
||||
object_keys(&weak),
|
||||
BTreeSet::from(["component", "installedAt", "packagePath", "serviceName"])
|
||||
);
|
||||
assert_eq!(weak["component"], "proxyfier");
|
||||
assert_eq!(weak["serviceName"], "ProxiFyreService");
|
||||
|
||||
let strong = read_json(root.join("proxywarden-component.json"));
|
||||
assert_eq!(
|
||||
object_keys(&strong),
|
||||
BTreeSet::from([
|
||||
"component",
|
||||
"installRoot",
|
||||
"manager",
|
||||
"packetFilterInstalledByProxyWarden",
|
||||
"serviceName",
|
||||
])
|
||||
);
|
||||
assert_eq!(strong["manager"], "ProxyWarden");
|
||||
assert_eq!(strong["component"], "proxifyre");
|
||||
assert_eq!(strong["serviceName"], "ProxiFyreService");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_generated_fixture_maps_to_canonical_state_and_regenerates() {
|
||||
let root = fixture_root();
|
||||
let source_path = root.join("proxifyre-generated/app-config.json");
|
||||
let source_before = fs::read(&source_path).expect("legacy generated fixture bytes");
|
||||
let source = read_json(&source_path);
|
||||
|
||||
let (profiles, targets) = strict_import_generated_proxifyre(&source)
|
||||
.expect("historically generated config must be strictly importable");
|
||||
let expected_profiles: Vec<Profile> =
|
||||
serde_json::from_value(read_json(root.join("pre-1.2-split/config/profiles.json")))
|
||||
.expect("expected profiles");
|
||||
let expected_targets: Vec<Target> =
|
||||
serde_json::from_value(read_json(root.join("pre-1.2-split/config/targets.json")))
|
||||
.expect("expected targets");
|
||||
assert_eq!(profiles, expected_profiles);
|
||||
assert_eq!(targets, expected_targets);
|
||||
|
||||
let regenerated = ProxiFyreAdapter::default()
|
||||
.generate_proxifyre_config(ProxyRouterRequest::new(&profiles, &targets, &[]))
|
||||
.expect("canonical state must regenerate");
|
||||
assert_eq!(
|
||||
serde_json::to_value(regenerated).expect("regenerated JSON"),
|
||||
source
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&source_path).expect("legacy source after import attempt"),
|
||||
source_before,
|
||||
"fixture importer must not mutate its legacy source"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_unsupported_legacy_variant_fails_closed_without_mutating_source() {
|
||||
let root = fixture_root();
|
||||
let supported = read_json(root.join("proxifyre-generated/app-config.json"));
|
||||
let unsupported_path = root.join("proxifyre-unsupported/app-config.json");
|
||||
let unsupported_before = fs::read(&unsupported_path).expect("unsupported source bytes");
|
||||
let unsupported = read_json(&unsupported_path);
|
||||
|
||||
assert_eq!(
|
||||
object_keys(&unsupported),
|
||||
BTreeSet::from(["bypassLan", "customRootField", "logLevel", "proxies"])
|
||||
);
|
||||
assert_eq!(
|
||||
object_keys(&unsupported["proxies"][0]),
|
||||
BTreeSet::from([
|
||||
"addressFamily",
|
||||
"appNames",
|
||||
"customProxyField",
|
||||
"password",
|
||||
"socks5ProxyEndpoint",
|
||||
"supportedProtocols",
|
||||
"tls",
|
||||
"username",
|
||||
])
|
||||
);
|
||||
assert!(strict_import_generated_proxifyre(&unsupported).is_err());
|
||||
|
||||
let variants = unsupported_variants(&supported);
|
||||
for (label, variant) in variants {
|
||||
assert!(
|
||||
strict_import_generated_proxifyre(&variant).is_err(),
|
||||
"unsupported variant was accepted: {label}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
fs::read(&unsupported_path).expect("unsupported source after validation"),
|
||||
unsupported_before,
|
||||
"validation must preserve unsupported legacy source bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_plain_socks5_fixture_roundtrips_semantically() {
|
||||
let root = fixture_root();
|
||||
let profiles: Vec<Profile> =
|
||||
serde_json::from_value(read_json(root.join("pre-1.2-split/config/profiles.json")))
|
||||
.expect("legacy profiles fixture");
|
||||
let targets: Vec<Target> =
|
||||
serde_json::from_value(read_json(root.join("pre-1.2-split/config/targets.json")))
|
||||
.expect("legacy targets fixture");
|
||||
let expected = read_json(root.join("proxifyre-generated/app-config.json"));
|
||||
|
||||
let generated = ProxiFyreAdapter::default()
|
||||
.generate_proxifyre_config(ProxyRouterRequest::new(&profiles, &targets, &[]))
|
||||
.expect("supported fixture must generate");
|
||||
let actual = serde_json::to_value(generated).expect("generated config JSON");
|
||||
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pre_1_2_split_fixture_still_deserializes_with_current_defaults() {
|
||||
let root = fixture_root().join("pre-1.2-split/config");
|
||||
let components: Vec<ComponentStatus> =
|
||||
serde_json::from_value(read_json(root.join("components.json")))
|
||||
.expect("legacy components fixture");
|
||||
let local_singbox: LocalSingBoxConfig =
|
||||
serde_json::from_value(read_json(root.join("local-singbox.json")))
|
||||
.expect("legacy local sing-box fixture");
|
||||
|
||||
assert!(components.iter().all(|component| {
|
||||
component.service_name.is_none() && component.service_status.is_none()
|
||||
}));
|
||||
assert!(local_singbox.device_hwid.is_none());
|
||||
assert!(local_singbox.selected_server_id.is_none());
|
||||
assert_eq!(
|
||||
local_singbox.install_root,
|
||||
r"C:\Program Files\ProxyWarden\sing-box"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_matrix_is_total_and_fail_closed() {
|
||||
let contract = contract();
|
||||
let matrix = contract["proxifyreFieldMatrix"]
|
||||
.as_array()
|
||||
.expect("field matrix");
|
||||
let expected_ids = BTreeSet::from([
|
||||
"address-family",
|
||||
"app-names",
|
||||
"bypass-lan-other",
|
||||
"bypass-lan-true",
|
||||
"credentials-userinfo",
|
||||
"endpoint-scheme-or-userinfo",
|
||||
"log-level-info",
|
||||
"log-level-other",
|
||||
"plain-endpoint",
|
||||
"protocol-other-or-empty",
|
||||
"protocol-tcp",
|
||||
"protocol-udp",
|
||||
"proxies",
|
||||
"tls",
|
||||
"unknown-proxy-key",
|
||||
"unknown-root-key",
|
||||
]);
|
||||
let fixture_ids: BTreeSet<&str> = contract["fixtures"]
|
||||
.as_array()
|
||||
.expect("fixtures array")
|
||||
.iter()
|
||||
.filter_map(|case| case["id"].as_str())
|
||||
.collect();
|
||||
|
||||
let mut actual_ids = BTreeSet::new();
|
||||
for rule in matrix {
|
||||
let id = rule["id"].as_str().expect("matrix rule id");
|
||||
assert!(actual_ids.insert(id), "duplicate matrix rule {id}");
|
||||
let outcome = rule["outcome"].as_str().expect("matrix outcome");
|
||||
assert!(
|
||||
matches!(outcome, "canonical" | "derived" | "unsupported"),
|
||||
"invalid matrix outcome for {id}: {outcome}"
|
||||
);
|
||||
if outcome != "unsupported" {
|
||||
assert!(
|
||||
rule["destination"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty()),
|
||||
"supported rule {id} must identify its destination"
|
||||
);
|
||||
}
|
||||
|
||||
let coverage = rule["coverage"].as_str().expect("matrix coverage");
|
||||
assert!(
|
||||
fixture_ids.contains(coverage) || coverage.starts_with("inline-"),
|
||||
"matrix rule {id} has unknown coverage {coverage}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(actual_ids, expected_ids);
|
||||
assert_eq!(
|
||||
fixture_case(&contract, "proxifyre-unsupported")["status"],
|
||||
"unsupported_preserve_original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_source_precedence_roots_services_collisions_and_state_are_frozen() {
|
||||
let contract = contract();
|
||||
|
||||
let split_sources: BTreeSet<&str> = contract["startup"]["canonicalSplitSourceFiles"]
|
||||
.as_array()
|
||||
.expect("split sources")
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
split_sources,
|
||||
BTreeSet::from([
|
||||
"config/components.json",
|
||||
"config/local-singbox.json",
|
||||
"config/profiles.json",
|
||||
"config/targets.json",
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
contract["startup"]["rules"]["anySplitSourceExists"],
|
||||
"adopt_split_without_generated_import"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
strings_at(
|
||||
&contract,
|
||||
"/components/proxifyre/confirmedManagedLegacyDefaultRoots"
|
||||
),
|
||||
BTreeSet::from([r"C:\Tools\ProxiFyre"])
|
||||
);
|
||||
assert_eq!(
|
||||
strings_at(
|
||||
&contract,
|
||||
"/components/singbox/confirmedManagedLegacyDefaultRoots"
|
||||
),
|
||||
BTreeSet::from([r"C:\Program Files\ProxyWarden\sing-box"])
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_paths_at(&contract, "/components/proxifyre/legacyCandidates"),
|
||||
BTreeSet::from([
|
||||
r"%LOCALAPPDATA%\ProxiFyre",
|
||||
r"%LOCALAPPDATA%\ProxyWarden\ProxiFyre",
|
||||
r"%ProgramFiles(x86)%\ProxiFyre",
|
||||
r"%ProgramFiles(x86)%\ProxyWarden\ProxiFyre",
|
||||
r"%ProgramFiles%\ProxiFyre",
|
||||
r"%ProgramFiles%\ProxyWarden\ProxiFyre",
|
||||
r"C:\Tools\ProxiFyre",
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_paths_at(&contract, "/components/singbox/legacyCandidates"),
|
||||
BTreeSet::from([
|
||||
r"%LOCALAPPDATA%\ProxyWarden\sing-box",
|
||||
r"%ProgramFiles(x86)%\ProxyWarden\sing-box",
|
||||
r"%ProgramFiles%\ProxyWarden\sing-box",
|
||||
r"C:\Tools\ProxyWarden\sing-box",
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["service"]["primaryName"],
|
||||
"ProxiFyreService"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["service"]["discoveryOnlyPathNameTemplate"],
|
||||
r#""{root}\ProxiFyre.exe" --service"#
|
||||
);
|
||||
assert_eq!(
|
||||
strings_at(
|
||||
&contract,
|
||||
"/components/proxifyre/service/discoveryOnlyAliases"
|
||||
),
|
||||
BTreeSet::from(["ProxiFyre"])
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["service"]["autoCutoverPathNameTemplate"],
|
||||
r#""{root}\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename "ProxiFyreService""#
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["singbox"]["service"]["primaryName"],
|
||||
"ProxyWardenSingBox"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["singbox"]["service"]["pathNameTemplate"],
|
||||
r#""{root}\ProxyWardenSingBox.exe""#
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["markers"]["managedLegacyRoot"],
|
||||
"none"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["markers"]["weakStandaloneScriptHint"]
|
||||
["ownershipProof"],
|
||||
false
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["markers"]["managedCurrent"]["requiredValues"]
|
||||
["manager"],
|
||||
"ProxyWarden"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["markers"]["managedCurrent"]["requiredValues"]
|
||||
["serviceName"],
|
||||
"ProxiFyreService"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["singbox"]["markers"]["managedLegacyRoot"],
|
||||
"none"
|
||||
);
|
||||
assert!(strings_at(
|
||||
&contract,
|
||||
"/components/proxifyre/managedLegacyClassificationRequires"
|
||||
)
|
||||
.contains("service PathName points to that exact executable"));
|
||||
assert!(strings_at(
|
||||
&contract,
|
||||
"/components/singbox/managedLegacyClassificationRequires"
|
||||
)
|
||||
.contains("service PathName points to that exact wrapper"));
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["autoCutover"]["root"],
|
||||
r"C:\Tools\ProxiFyre"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["autoCutover"]["allOtherDiscoveryCandidates"]
|
||||
["decision"],
|
||||
"manual_migration_required"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["autoCutover"]["allOtherDiscoveryCandidates"]
|
||||
["mutationPlan"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["singbox"]["autoCutover"]["decision"],
|
||||
"manual_migration_required"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["components"]["singbox"]["autoCutover"]["mutationPlan"],
|
||||
serde_json::json!([])
|
||||
);
|
||||
|
||||
let frozen_manifest = contract["components"]["proxifyre"]["autoCutover"]["packageManifest"]
|
||||
.as_array()
|
||||
.expect("frozen ProxiFyre package manifest");
|
||||
assert_eq!(frozen_manifest.len(), LEGACY_PROXIFYRE_2_2_1_MANIFEST.len());
|
||||
for expected in LEGACY_PROXIFYRE_2_2_1_MANIFEST {
|
||||
let actual = frozen_manifest
|
||||
.iter()
|
||||
.find(|file| file["relativePath"] == expected.relative_path)
|
||||
.unwrap_or_else(|| panic!("missing frozen package file {}", expected.relative_path));
|
||||
assert_eq!(actual["size"], expected.size);
|
||||
assert_eq!(actual["sha256"], expected.sha256);
|
||||
}
|
||||
assert_eq!(
|
||||
contract["components"]["proxifyre"]["autoCutover"]["scmProfile"],
|
||||
serde_json::json!({
|
||||
"serviceType": "win32_own_process",
|
||||
"startType": "auto_start",
|
||||
"errorControl": "normal",
|
||||
"account": "LocalSystem",
|
||||
"displayName": "ProxiFyre Service",
|
||||
"description": "ProxiFyre - SOCKS5 ProxiFyre Service",
|
||||
"dependencies": [],
|
||||
"loadOrderGroup": null,
|
||||
"failureActions": [],
|
||||
"failureActionsOnNonCrash": false,
|
||||
"delayedAutoStart": false,
|
||||
"sidType": "none",
|
||||
"requiredPrivileges": [],
|
||||
"triggers": [],
|
||||
"untrustedMutationRights": false
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
candidate_paths_at(&contract, "/components/singbox/foreignByDefaultCandidates"),
|
||||
BTreeSet::from([
|
||||
r"%LOCALAPPDATA%\sing-box",
|
||||
r"%ProgramFiles(x86)%\sing-box",
|
||||
r"%ProgramFiles%\sing-box",
|
||||
])
|
||||
);
|
||||
|
||||
let history_commits: BTreeSet<&str> = contract["historyEvidence"]
|
||||
.as_array()
|
||||
.expect("history evidence")
|
||||
.iter()
|
||||
.filter_map(|item| item["commit"].as_str())
|
||||
.collect();
|
||||
for pointer in [
|
||||
"/components/proxifyre/legacyCandidates",
|
||||
"/components/singbox/legacyCandidates",
|
||||
"/components/singbox/foreignByDefaultCandidates",
|
||||
] {
|
||||
for candidate in contract
|
||||
.pointer(pointer)
|
||||
.expect("candidate list")
|
||||
.as_array()
|
||||
.expect("candidate array")
|
||||
{
|
||||
let commit = candidate["evidenceCommit"]
|
||||
.as_str()
|
||||
.expect("candidate evidence commit");
|
||||
assert!(
|
||||
history_commits.contains(commit),
|
||||
"candidate evidence commit is absent from historyEvidence: {commit}"
|
||||
);
|
||||
assert!(
|
||||
candidate["evidenceFile"]
|
||||
.as_str()
|
||||
.is_some_and(|path| path.starts_with("src-tauri/src/")),
|
||||
"candidate must name its historical source file"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
contract["collisionPolicy"]["currentAndLegacy"],
|
||||
"current_wins_orphan_untouched_manual"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["collisionPolicy"]["sameServiceNameForeignPath"],
|
||||
"ownership_mismatch_without_mutation"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["runningStatePolicy"]["running"],
|
||||
"restore_running_after_success_or_rollback"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["runningStatePolicy"]["stopped"],
|
||||
"keep_stopped_after_success_or_rollback"
|
||||
);
|
||||
assert_eq!(
|
||||
contract["runningStatePolicy"]["pendingOrUnknown"],
|
||||
"block_without_mutation"
|
||||
);
|
||||
}
|
||||
|
||||
fn strict_import_generated_proxifyre(
|
||||
value: &Value,
|
||||
) -> Result<(Vec<Profile>, Vec<Target>), &'static str> {
|
||||
if object_keys(value) != BTreeSet::from(["bypassLan", "logLevel", "proxies"]) {
|
||||
return Err("unsupported root fields");
|
||||
}
|
||||
if value["logLevel"] != "Info" {
|
||||
return Err("unsupported log level");
|
||||
}
|
||||
if value["bypassLan"] != true {
|
||||
return Err("unsupported bypassLan");
|
||||
}
|
||||
|
||||
let proxies = value["proxies"]
|
||||
.as_array()
|
||||
.ok_or("proxies must be an array")?;
|
||||
if proxies.is_empty() {
|
||||
return Err("generated config contains no recoverable proxies");
|
||||
}
|
||||
|
||||
let mut profiles = Vec::with_capacity(proxies.len());
|
||||
let mut targets = Vec::with_capacity(proxies.len());
|
||||
for (index, proxy) in proxies.iter().enumerate() {
|
||||
if object_keys(proxy)
|
||||
!= BTreeSet::from(["appNames", "socks5ProxyEndpoint", "supportedProtocols"])
|
||||
{
|
||||
return Err("unsupported proxy fields");
|
||||
}
|
||||
|
||||
let app_names = proxy["appNames"]
|
||||
.as_array()
|
||||
.ok_or("appNames must be an array")?;
|
||||
if app_names.is_empty() {
|
||||
return Err("appNames must not be empty");
|
||||
}
|
||||
let mut items = Vec::with_capacity(app_names.len());
|
||||
for app_name in app_names {
|
||||
let app_name = app_name.as_str().ok_or("app name must be a string")?;
|
||||
if app_name.trim().is_empty() {
|
||||
return Err("app name must not be empty");
|
||||
}
|
||||
let is_path = app_name.contains(['\\', '/']);
|
||||
let (item_type, recursive) =
|
||||
if is_path && app_name.to_ascii_lowercase().ends_with(".exe") {
|
||||
(ProfileItemType::Exe, false)
|
||||
} else if is_path {
|
||||
(ProfileItemType::Folder, true)
|
||||
} else {
|
||||
(ProfileItemType::Process, false)
|
||||
};
|
||||
items.push(ProfileItem {
|
||||
item_type,
|
||||
value: app_name.to_string(),
|
||||
recursive,
|
||||
});
|
||||
}
|
||||
|
||||
let endpoint = proxy["socks5ProxyEndpoint"]
|
||||
.as_str()
|
||||
.ok_or("endpoint must be a string")?;
|
||||
let (host, port) = strict_plain_endpoint(endpoint)?;
|
||||
|
||||
let protocol_values = proxy["supportedProtocols"]
|
||||
.as_array()
|
||||
.ok_or("supportedProtocols must be an array")?;
|
||||
if protocol_values.is_empty() {
|
||||
return Err("supportedProtocols must not be empty");
|
||||
}
|
||||
let mut protocols = Vec::with_capacity(protocol_values.len());
|
||||
for protocol in protocol_values {
|
||||
let protocol = match protocol.as_str() {
|
||||
Some("TCP") => Protocol::Tcp,
|
||||
Some("UDP") => Protocol::Udp,
|
||||
_ => return Err("unsupported protocol"),
|
||||
};
|
||||
if protocols.contains(&protocol) {
|
||||
return Err("duplicate protocol");
|
||||
}
|
||||
protocols.push(protocol);
|
||||
}
|
||||
|
||||
let ordinal = index + 1;
|
||||
let profile_id = if proxies.len() == 1 {
|
||||
"fixture-profile".to_string()
|
||||
} else {
|
||||
format!("legacy-proxifyre-profile-{ordinal}")
|
||||
};
|
||||
let target_id = if proxies.len() == 1 {
|
||||
"fixture-target".to_string()
|
||||
} else {
|
||||
format!("legacy-proxifyre-target-{ordinal}")
|
||||
};
|
||||
profiles.push(Profile {
|
||||
id: profile_id,
|
||||
name: if proxies.len() == 1 {
|
||||
"Fixture profile".to_string()
|
||||
} else {
|
||||
format!("Legacy ProxiFyre profile {ordinal}")
|
||||
},
|
||||
enabled: true,
|
||||
target_id: target_id.clone(),
|
||||
protocols,
|
||||
items,
|
||||
});
|
||||
targets.push(Target {
|
||||
id: target_id,
|
||||
name: if proxies.len() == 1 {
|
||||
"Fixture target".to_string()
|
||||
} else {
|
||||
format!("Legacy ProxiFyre target {ordinal}")
|
||||
},
|
||||
kind: TargetKind::External,
|
||||
protocol: ProxyProtocol::Socks5,
|
||||
host,
|
||||
port,
|
||||
requires_component: None,
|
||||
});
|
||||
}
|
||||
|
||||
Ok((profiles, targets))
|
||||
}
|
||||
|
||||
fn strict_plain_endpoint(endpoint: &str) -> Result<(String, u16), &'static str> {
|
||||
if endpoint.contains(['/', '@']) || endpoint.matches(':').count() != 1 {
|
||||
return Err("endpoint must be plain host:port");
|
||||
}
|
||||
let (host, port) = endpoint
|
||||
.rsplit_once(':')
|
||||
.ok_or("endpoint must include a port")?;
|
||||
if host.trim().is_empty() || host.chars().any(char::is_whitespace) {
|
||||
return Err("endpoint host is invalid");
|
||||
}
|
||||
let port = port
|
||||
.parse::<u16>()
|
||||
.map_err(|_| "endpoint port is invalid")?;
|
||||
if port == 0 {
|
||||
return Err("endpoint port must not be zero");
|
||||
}
|
||||
Ok((host.to_string(), port))
|
||||
}
|
||||
|
||||
fn unsupported_variants(supported: &Value) -> Vec<(&'static str, Value)> {
|
||||
let mut variants = Vec::new();
|
||||
let mut add = |label, mutate: fn(&mut Value)| {
|
||||
let mut value = supported.clone();
|
||||
mutate(&mut value);
|
||||
variants.push((label, value));
|
||||
};
|
||||
|
||||
add("non-default logLevel", |value| {
|
||||
value["logLevel"] = "Debug".into()
|
||||
});
|
||||
add("non-default bypassLan", |value| {
|
||||
value["bypassLan"] = false.into()
|
||||
});
|
||||
add("empty proxies", |value| {
|
||||
value["proxies"] = serde_json::json!([])
|
||||
});
|
||||
add("empty appNames", |value| {
|
||||
value["proxies"][0]["appNames"] = serde_json::json!([])
|
||||
});
|
||||
add("scheme endpoint", |value| {
|
||||
value["proxies"][0]["socks5ProxyEndpoint"] = "socks5://proxy.example.test:1080".into()
|
||||
});
|
||||
add("userinfo endpoint", |value| {
|
||||
value["proxies"][0]["socks5ProxyEndpoint"] = "fixture-user@proxy.example.test:1080".into()
|
||||
});
|
||||
add("missing endpoint port", |value| {
|
||||
value["proxies"][0]["socks5ProxyEndpoint"] = "proxy.example.test".into()
|
||||
});
|
||||
add("zero endpoint port", |value| {
|
||||
value["proxies"][0]["socks5ProxyEndpoint"] = "proxy.example.test:0".into()
|
||||
});
|
||||
add("empty protocols", |value| {
|
||||
value["proxies"][0]["supportedProtocols"] = serde_json::json!([])
|
||||
});
|
||||
add("unknown protocol", |value| {
|
||||
value["proxies"][0]["supportedProtocols"] = serde_json::json!(["TCP", "ICMP"])
|
||||
});
|
||||
add("username", |value| {
|
||||
value["proxies"][0]["username"] = "__REDACTED_USERNAME__".into()
|
||||
});
|
||||
add("password", |value| {
|
||||
value["proxies"][0]["password"] = "__REDACTED_PASSWORD__".into()
|
||||
});
|
||||
add("userinfo field", |value| {
|
||||
value["proxies"][0]["userinfo"] = "__REDACTED_USERINFO__".into()
|
||||
});
|
||||
add("tls", |value| {
|
||||
value["proxies"][0]["tls"] = serde_json::json!({"enabled": true})
|
||||
});
|
||||
add("address family", |value| {
|
||||
value["proxies"][0]["addressFamily"] = "IPv4".into()
|
||||
});
|
||||
add("unknown root key", |value| {
|
||||
value["customRootField"] = "REDACTED".into()
|
||||
});
|
||||
add("unknown proxy key", |value| {
|
||||
value["proxies"][0]["customProxyField"] = "REDACTED".into()
|
||||
});
|
||||
|
||||
variants
|
||||
}
|
||||
|
||||
fn object_keys(value: &Value) -> BTreeSet<&str> {
|
||||
value
|
||||
.as_object()
|
||||
.map(|object| object.keys().map(String::as_str).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn collect_json_files(directory: &Path, output: &mut Vec<PathBuf>) {
|
||||
let mut entries: Vec<_> = fs::read_dir(directory)
|
||||
.unwrap_or_else(|error| panic!("failed to read {}: {error}", directory.display()))
|
||||
.map(|entry| entry.expect("fixture directory entry").path())
|
||||
.collect();
|
||||
entries.sort();
|
||||
|
||||
for path in entries {
|
||||
if path.is_dir() {
|
||||
collect_json_files(&path, output);
|
||||
} else if path.extension().and_then(|value| value.to_str()) == Some("json") {
|
||||
output.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_sanitized(value: &Value, path: &str, key: Option<&str>) -> Result<(), String> {
|
||||
if key.is_some_and(is_sensitive_key) && !is_safe_sensitive_value(value) {
|
||||
return Err(format!("{path}: sensitive fixture value is not redacted"));
|
||||
}
|
||||
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
for (child_key, child_value) in object {
|
||||
assert_sanitized(child_value, &format!("{path}.{child_key}"), Some(child_key))?;
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for (index, child) in array.iter().enumerate() {
|
||||
assert_sanitized(child, &format!("{path}[{index}]"), key)?;
|
||||
}
|
||||
}
|
||||
Value::String(text) => assert_safe_string(text, path)?,
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_sensitive_key(key: &str) -> bool {
|
||||
matches!(
|
||||
key.to_ascii_lowercase().replace(['_', '-'], "").as_str(),
|
||||
"username"
|
||||
| "password"
|
||||
| "token"
|
||||
| "secret"
|
||||
| "subscriptionurl"
|
||||
| "userinfo"
|
||||
| "authorization"
|
||||
)
|
||||
}
|
||||
|
||||
fn is_safe_sensitive_value(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => true,
|
||||
Value::String(text)
|
||||
if matches!(
|
||||
text.as_str(),
|
||||
"__REDACTED_USERNAME__"
|
||||
| "__REDACTED_PASSWORD__"
|
||||
| "__REDACTED_USERINFO__"
|
||||
| "__REDACTED_TOKEN__"
|
||||
) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
Value::String(text) => synthetic_url_is_safe(text),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_safe_string(text: &str, path: &str) -> Result<(), String> {
|
||||
if text.contains("://") && !synthetic_url_is_safe(text) {
|
||||
return Err(format!("{path}: fixture URL is not safely synthetic"));
|
||||
}
|
||||
if text.contains('@') {
|
||||
return Err(format!(
|
||||
"{path}: fixture endpoint must not contain userinfo"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn synthetic_url_is_safe(text: &str) -> bool {
|
||||
let Ok(url) = Url::parse(text) else {
|
||||
return false;
|
||||
};
|
||||
let synthetic_host = url
|
||||
.host_str()
|
||||
.is_some_and(|host| host == "example.test" || host.ends_with(".example.test"));
|
||||
synthetic_host
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.query().is_none()
|
||||
&& url.fragment().is_none()
|
||||
}
|
||||
|
||||
fn strings_at<'a>(value: &'a Value, pointer: &str) -> BTreeSet<&'a str> {
|
||||
value
|
||||
.pointer(pointer)
|
||||
.unwrap_or_else(|| panic!("missing contract pointer {pointer}"))
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("contract pointer is not an array: {pointer}"))
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn candidate_paths_at<'a>(value: &'a Value, pointer: &str) -> BTreeSet<&'a str> {
|
||||
value
|
||||
.pointer(pointer)
|
||||
.unwrap_or_else(|| panic!("missing contract pointer {pointer}"))
|
||||
.as_array()
|
||||
.unwrap_or_else(|| panic!("contract pointer is not an array: {pointer}"))
|
||||
.iter()
|
||||
.filter_map(|candidate| candidate["path"].as_str())
|
||||
.collect()
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"historyEvidence": [
|
||||
{
|
||||
"commit": "c5120669d2b86f417f6dbd8fc7e01eeafbcea3ab",
|
||||
"proves": "split storage and the generated ProxiFyre config shape"
|
||||
},
|
||||
{
|
||||
"commit": "e745633d91880b2f795fee2496d7fb4c35c54a38",
|
||||
"proves": "the later opportunistic generated-config bootstrap"
|
||||
},
|
||||
{
|
||||
"commit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"proves": "the historical component candidate roots"
|
||||
},
|
||||
{
|
||||
"commit": "dbba3806ccdc8c4792d59add359c7ade7fff1176",
|
||||
"proves": "the current app-adjacent component layout and strong ProxiFyre marker"
|
||||
}
|
||||
],
|
||||
"startup": {
|
||||
"canonicalSplitSourceFiles": [
|
||||
"config/profiles.json",
|
||||
"config/targets.json",
|
||||
"config/components.json",
|
||||
"config/local-singbox.json"
|
||||
],
|
||||
"generatedRecoverySource": "generated/proxifyre-app-config.json",
|
||||
"rules": {
|
||||
"currentMetadata": "no_op",
|
||||
"anySplitSourceExists": "adopt_split_without_generated_import",
|
||||
"allSplitSourcesAbsentAndOneSupportedGeneratedConfig": "import_once",
|
||||
"partialOrCorruptSplit": "recover_or_warn_without_generated_merge"
|
||||
}
|
||||
},
|
||||
"fixtures": [
|
||||
{
|
||||
"id": "proxifyre-generated",
|
||||
"status": "supported",
|
||||
"files": [
|
||||
"proxifyre-generated/app-config.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "proxifyre-unsupported",
|
||||
"status": "unsupported_preserve_original",
|
||||
"files": [
|
||||
"proxifyre-unsupported/app-config.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "proxifyre-real-sanitized",
|
||||
"status": "supported_structure_preserving_sanitized_sample",
|
||||
"files": [
|
||||
"proxifyre-real-sanitized/app-config.json"
|
||||
],
|
||||
"provenance": {
|
||||
"capturedAt": "2026-08-17",
|
||||
"source": "C:\\ProgramData\\ProxyWarden\\generated\\proxifyre-app-config.json on a pre-1.2 local installation",
|
||||
"sourceSha256": "078C3E74B96D26DF155229B2F6FD380F762550B0A3F863CE2E913F5B6493F710",
|
||||
"preserved": "root/proxy key sets, proxy count, app count and app value categories, protocol values, default flags and endpoint shape",
|
||||
"replaced": "all app names, filesystem paths, hostnames and ports"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "marker-formats",
|
||||
"status": "schema_evidence_only",
|
||||
"files": [
|
||||
"markers/install-proxyfier.marker.json",
|
||||
"markers/proxywarden-component.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "pre-1.2-split",
|
||||
"status": "adopt_without_generated_import",
|
||||
"files": [
|
||||
"pre-1.2-split/config/profiles.json",
|
||||
"pre-1.2-split/config/targets.json",
|
||||
"pre-1.2-split/config/components.json",
|
||||
"pre-1.2-split/config/local-singbox.json"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proxifyreFieldMatrix": [
|
||||
{
|
||||
"id": "log-level-info",
|
||||
"jsonPath": "$.logLevel == Info",
|
||||
"outcome": "derived",
|
||||
"destination": "generator default Info",
|
||||
"coverage": "proxifyre-generated"
|
||||
},
|
||||
{
|
||||
"id": "log-level-other",
|
||||
"jsonPath": "$.logLevel != Info",
|
||||
"outcome": "unsupported",
|
||||
"coverage": "proxifyre-unsupported"
|
||||
},
|
||||
{
|
||||
"id": "bypass-lan-true",
|
||||
"jsonPath": "$.bypassLan == true",
|
||||
"outcome": "derived",
|
||||
"destination": "generator default true",
|
||||
"coverage": "proxifyre-generated"
|
||||
},
|
||||
{
|
||||
"id": "bypass-lan-other",
|
||||
"jsonPath": "$.bypassLan != true",
|
||||
"outcome": "unsupported",
|
||||
"coverage": "proxifyre-unsupported"
|
||||
},
|
||||
{
|
||||
"id": "proxies",
|
||||
"jsonPath": "$.proxies[*]",
|
||||
"outcome": "derived",
|
||||
"destination": "one enabled profile and target pair per entry",
|
||||
"coverage": "proxifyre-generated"
|
||||
},
|
||||
{
|
||||
"id": "app-names",
|
||||
"jsonPath": "$.proxies[*].appNames[*]",
|
||||
"outcome": "canonical",
|
||||
"destination": "profiles[*].items; folder recursive is derived because the generated format cannot represent it",
|
||||
"coverage": "proxifyre-generated"
|
||||
},
|
||||
{
|
||||
"id": "plain-endpoint",
|
||||
"jsonPath": "$.proxies[*].socks5ProxyEndpoint plain host:port",
|
||||
"outcome": "canonical",
|
||||
"destination": "targets[*].host and targets[*].port",
|
||||
"coverage": "proxifyre-generated"
|
||||
},
|
||||
{
|
||||
"id": "endpoint-scheme-or-userinfo",
|
||||
"jsonPath": "$.proxies[*].socks5ProxyEndpoint with scheme, userinfo, missing port, or port 0",
|
||||
"outcome": "unsupported",
|
||||
"coverage": "inline-uri-userinfo"
|
||||
},
|
||||
{
|
||||
"id": "protocol-tcp",
|
||||
"jsonPath": "$.proxies[*].supportedProtocols[*] == TCP",
|
||||
"outcome": "canonical",
|
||||
"destination": "profiles[*].protocols TCP",
|
||||
"coverage": "proxifyre-generated"
|
||||
},
|
||||
{
|
||||
"id": "protocol-udp",
|
||||
"jsonPath": "$.proxies[*].supportedProtocols[*] == UDP",
|
||||
"outcome": "canonical",
|
||||
"destination": "profiles[*].protocols UDP",
|
||||
"coverage": "proxifyre-generated"
|
||||
},
|
||||
{
|
||||
"id": "protocol-other-or-empty",
|
||||
"jsonPath": "$.proxies[*].supportedProtocols empty or value other than TCP/UDP",
|
||||
"outcome": "unsupported",
|
||||
"coverage": "proxifyre-unsupported"
|
||||
},
|
||||
{
|
||||
"id": "credentials-userinfo",
|
||||
"jsonPath": "$.proxies[*].username/password/userinfo",
|
||||
"outcome": "unsupported",
|
||||
"coverage": "proxifyre-unsupported"
|
||||
},
|
||||
{
|
||||
"id": "tls",
|
||||
"jsonPath": "$.proxies[*].tls",
|
||||
"outcome": "unsupported",
|
||||
"coverage": "proxifyre-unsupported"
|
||||
},
|
||||
{
|
||||
"id": "address-family",
|
||||
"jsonPath": "$.proxies[*].addressFamily",
|
||||
"outcome": "unsupported",
|
||||
"coverage": "proxifyre-unsupported"
|
||||
},
|
||||
{
|
||||
"id": "unknown-root-key",
|
||||
"jsonPath": "$.* unknown root key",
|
||||
"outcome": "unsupported",
|
||||
"coverage": "proxifyre-unsupported"
|
||||
},
|
||||
{
|
||||
"id": "unknown-proxy-key",
|
||||
"jsonPath": "$.proxies[*].* unknown proxy key",
|
||||
"outcome": "unsupported",
|
||||
"coverage": "proxifyre-unsupported"
|
||||
}
|
||||
],
|
||||
"components": {
|
||||
"proxifyre": {
|
||||
"managedCurrentRootTemplate": "{controlAppDir}\\components\\ProxiFyre",
|
||||
"confirmedManagedLegacyDefaultRoots": [
|
||||
"C:\\Tools\\ProxiFyre"
|
||||
],
|
||||
"legacyCandidates": [
|
||||
{
|
||||
"path": "C:\\Tools\\ProxiFyre",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/commands.rs"
|
||||
},
|
||||
{
|
||||
"path": "%ProgramFiles%\\ProxiFyre",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%ProgramFiles(x86)%\\ProxiFyre",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%LOCALAPPDATA%\\ProxiFyre",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%ProgramFiles%\\ProxyWarden\\ProxiFyre",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "dbba3806ccdc8c4792d59add359c7ade7fff1176",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%ProgramFiles(x86)%\\ProxyWarden\\ProxiFyre",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "dbba3806ccdc8c4792d59add359c7ade7fff1176",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%LOCALAPPDATA%\\ProxyWarden\\ProxiFyre",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "dbba3806ccdc8c4792d59add359c7ade7fff1176",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
}
|
||||
],
|
||||
"managedLegacyClassificationRequires": [
|
||||
"exact allowlisted root",
|
||||
"ProxiFyre.exe exists at that root",
|
||||
"service PathName points to that exact executable",
|
||||
"binary matches a known bundled package identity"
|
||||
],
|
||||
"service": {
|
||||
"primaryName": "ProxiFyreService",
|
||||
"discoveryOnlyAliases": [
|
||||
"ProxiFyre"
|
||||
],
|
||||
"discoveryOnlyPathNameTemplate": "\"{root}\\ProxiFyre.exe\" --service",
|
||||
"autoCutoverPathNameTemplate": "\"{root}\\ProxiFyre.exe\" -displayname \"ProxiFyre Service\" -servicename \"ProxiFyreService\""
|
||||
},
|
||||
"autoCutover": {
|
||||
"decision": "automatic_proxifyre_2_2_1",
|
||||
"root": "C:\\Tools\\ProxiFyre",
|
||||
"versionValues": [
|
||||
"2.2.1",
|
||||
"2.2.1.0"
|
||||
],
|
||||
"packageManifest": [
|
||||
{
|
||||
"relativePath": "Newtonsoft.Json.dll",
|
||||
"size": 711952,
|
||||
"sha256": "e1e27af7b07eeedf5ce71a9255f0422816a6fc5849a483c6714e1b472044fa9d"
|
||||
},
|
||||
{
|
||||
"relativePath": "Newtonsoft.Json.xml",
|
||||
"size": 713541,
|
||||
"sha256": "79ee87d4ede8783461de05b93379d576f6e8575d4ab49359f15897a854b643c4"
|
||||
},
|
||||
{
|
||||
"relativePath": "NLog.config",
|
||||
"size": 382,
|
||||
"sha256": "06b8e52be9385e4e6a2f042f0d7ca3dd0b043378b455535299846b02fd19250d"
|
||||
},
|
||||
{
|
||||
"relativePath": "NLog.dll",
|
||||
"size": 940032,
|
||||
"sha256": "4b1d3cf9f1f3c4a6ead141243069162172e9ef48ba1a9bf4f7ccd618b8194b5c"
|
||||
},
|
||||
{
|
||||
"relativePath": "NLog.xml",
|
||||
"size": 1608606,
|
||||
"sha256": "6871374d682e75aff17de2a8626a75e9c75409516f5e7527e9d159c1de6831bb"
|
||||
},
|
||||
{
|
||||
"relativePath": "ProxiFyre.exe",
|
||||
"size": 35960,
|
||||
"sha256": "2a60a76480715fca52185163d7ac6d850d4b0abe4079b7d461d7d0fcb3f02d93"
|
||||
},
|
||||
{
|
||||
"relativePath": "ProxiFyre.exe.config",
|
||||
"size": 177,
|
||||
"sha256": "8403846edd2ee98fd53b351dbf8773951c8e30f4b04dd53676a7e7dfbd8930b0"
|
||||
},
|
||||
{
|
||||
"relativePath": "socksify.dll",
|
||||
"size": 1309184,
|
||||
"sha256": "940b22ae8e97ff575317cc4a6c20467ed2ff760d01c7b056d13fcb20e7043cbd"
|
||||
},
|
||||
{
|
||||
"relativePath": "Topshelf.dll",
|
||||
"size": 190464,
|
||||
"sha256": "bd70a5832124e36840452ff46e442efa0a09a4ceba842aea72c79b2d322d7fe8"
|
||||
},
|
||||
{
|
||||
"relativePath": "Topshelf.xml",
|
||||
"size": 80754,
|
||||
"sha256": "3b2228b3333c4fd86e29020bc2d77a5260dbf03e911829d6226498ade53c2790"
|
||||
}
|
||||
],
|
||||
"scmProfile": {
|
||||
"serviceType": "win32_own_process",
|
||||
"startType": "auto_start",
|
||||
"errorControl": "normal",
|
||||
"account": "LocalSystem",
|
||||
"displayName": "ProxiFyre Service",
|
||||
"description": "ProxiFyre - SOCKS5 ProxiFyre Service",
|
||||
"dependencies": [],
|
||||
"loadOrderGroup": null,
|
||||
"failureActions": [],
|
||||
"failureActionsOnNonCrash": false,
|
||||
"delayedAutoStart": false,
|
||||
"sidType": "none",
|
||||
"requiredPrivileges": [],
|
||||
"triggers": [],
|
||||
"untrustedMutationRights": false
|
||||
},
|
||||
"allOtherDiscoveryCandidates": {
|
||||
"decision": "manual_migration_required",
|
||||
"mutationPlan": []
|
||||
}
|
||||
},
|
||||
"markers": {
|
||||
"managedLegacyRoot": "none",
|
||||
"weakStandaloneScriptHint": {
|
||||
"file": "install-proxyfier.marker.json",
|
||||
"fields": [
|
||||
"component",
|
||||
"packagePath",
|
||||
"serviceName",
|
||||
"installedAt"
|
||||
],
|
||||
"ownershipProof": false
|
||||
},
|
||||
"managedCurrent": {
|
||||
"file": "proxywarden-component.json",
|
||||
"requiredValues": {
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService"
|
||||
},
|
||||
"rootField": "installRoot",
|
||||
"packetFilterOwnershipField": "packetFilterInstalledByProxyWarden"
|
||||
}
|
||||
}
|
||||
},
|
||||
"singbox": {
|
||||
"managedCurrentRootTemplate": "{controlAppDir}\\components\\sing-box",
|
||||
"confirmedManagedLegacyDefaultRoots": [
|
||||
"C:\\Program Files\\ProxyWarden\\sing-box"
|
||||
],
|
||||
"legacyCandidates": [
|
||||
{
|
||||
"path": "C:\\Tools\\ProxyWarden\\sing-box",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%ProgramFiles%\\ProxyWarden\\sing-box",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%ProgramFiles(x86)%\\ProxyWarden\\sing-box",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%LOCALAPPDATA%\\ProxyWarden\\sing-box",
|
||||
"classificationBeforeIdentity": "candidate",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
}
|
||||
],
|
||||
"managedLegacyClassificationRequires": [
|
||||
"exact allowlisted root",
|
||||
"sing-box.exe and ProxyWardenSingBox.exe exist at that root",
|
||||
"service PathName points to that exact wrapper",
|
||||
"ProxyWardenSingBox.xml has matching id, executable, and config arguments",
|
||||
"binaries match known bundled package identities"
|
||||
],
|
||||
"foreignByDefaultCandidates": [
|
||||
{
|
||||
"path": "%ProgramFiles%\\sing-box",
|
||||
"classificationBeforeIdentity": "foreign",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%ProgramFiles(x86)%\\sing-box",
|
||||
"classificationBeforeIdentity": "foreign",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
},
|
||||
{
|
||||
"path": "%LOCALAPPDATA%\\sing-box",
|
||||
"classificationBeforeIdentity": "foreign",
|
||||
"evidenceCommit": "9fd0a8c0b9e8472aa8e9a983e6c07780f5236c4a",
|
||||
"evidenceFile": "src-tauri/src/component_detection.rs"
|
||||
}
|
||||
],
|
||||
"service": {
|
||||
"primaryName": "ProxyWardenSingBox",
|
||||
"pathNameTemplate": "\"{root}\\ProxyWardenSingBox.exe\"",
|
||||
"identityFiles": [
|
||||
"ProxyWardenSingBox.exe",
|
||||
"ProxyWardenSingBox.xml",
|
||||
"sing-box.exe"
|
||||
]
|
||||
},
|
||||
"markers": {
|
||||
"managedLegacyRoot": "none"
|
||||
},
|
||||
"autoCutover": {
|
||||
"decision": "manual_migration_required",
|
||||
"reason": "historical installer downloaded moving latest sing-box and WinSW-x64 without a frozen inner identity",
|
||||
"mutationPlan": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"collisionPolicy": {
|
||||
"currentAndLegacy": "current_wins_orphan_untouched_manual",
|
||||
"multipleLegacyCandidates": "block_without_mutation",
|
||||
"sameServiceNameForeignPath": "ownership_mismatch_without_mutation"
|
||||
},
|
||||
"runningStatePolicy": {
|
||||
"running": "restore_running_after_success_or_rollback",
|
||||
"stopped": "keep_stopped_after_success_or_rollback",
|
||||
"pendingOrUnknown": "block_without_mutation"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"component": "proxyfier",
|
||||
"packagePath": "C:\\Fixture\\Packages\\proxifyre-package.zip",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installedAt": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"manager": "ProxyWarden",
|
||||
"component": "proxifyre",
|
||||
"serviceName": "ProxiFyreService",
|
||||
"installRoot": "C:\\Fixture\\ProxyWarden\\components\\ProxiFyre",
|
||||
"packetFilterInstalledByProxyWarden": false
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[
|
||||
{
|
||||
"id": "proxyfier",
|
||||
"name": "ProxiFyre",
|
||||
"state": "installed",
|
||||
"installed": true,
|
||||
"running": false,
|
||||
"version": null,
|
||||
"path": "C:\\Tools\\ProxiFyre\\ProxiFyre.exe",
|
||||
"problems": [],
|
||||
"actions": []
|
||||
},
|
||||
{
|
||||
"id": "singbox",
|
||||
"name": "Local sing-box",
|
||||
"state": "missing",
|
||||
"installed": false,
|
||||
"running": false,
|
||||
"version": null,
|
||||
"path": null,
|
||||
"problems": [],
|
||||
"actions": []
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"subscription_url": "https://subscription.example.test/redacted",
|
||||
"selected_server_tag": "fixture-server",
|
||||
"listen_host": "127.0.0.1",
|
||||
"listen_port": 1080,
|
||||
"service_name": "ProxyWardenSingBox",
|
||||
"install_root": "C:\\Program Files\\ProxyWarden\\sing-box",
|
||||
"updated_at": null
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
[
|
||||
{
|
||||
"id": "fixture-profile",
|
||||
"name": "Fixture profile",
|
||||
"enabled": true,
|
||||
"target_id": "fixture-target",
|
||||
"protocols": [
|
||||
"TCP",
|
||||
"UDP"
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"type": "process",
|
||||
"value": "FixtureProcess",
|
||||
"recursive": false
|
||||
},
|
||||
{
|
||||
"type": "exe",
|
||||
"value": "C:\\Fixture\\Apps\\fixture.exe",
|
||||
"recursive": false
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"value": "C:\\Fixture\\Games",
|
||||
"recursive": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,11 @@
|
||||
[
|
||||
{
|
||||
"id": "fixture-target",
|
||||
"name": "Fixture target",
|
||||
"kind": "external",
|
||||
"protocol": "socks5",
|
||||
"host": "proxy.example.test",
|
||||
"port": 1080,
|
||||
"requires_component": null
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"logLevel": "Info",
|
||||
"bypassLan": true,
|
||||
"proxies": [
|
||||
{
|
||||
"appNames": [
|
||||
"FixtureProcess",
|
||||
"C:\\Fixture\\Apps\\fixture.exe",
|
||||
"C:\\Fixture\\Games"
|
||||
],
|
||||
"socks5ProxyEndpoint": "proxy.example.test:1080",
|
||||
"supportedProtocols": [
|
||||
"TCP",
|
||||
"UDP"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"logLevel": "Info",
|
||||
"bypassLan": true,
|
||||
"proxies": [
|
||||
{
|
||||
"appNames": [
|
||||
"FixtureProcess1",
|
||||
"FixtureProcess2",
|
||||
"FixtureProcess3",
|
||||
"FixtureProcess4",
|
||||
"FixtureProcess5",
|
||||
"C:\\Fixture\\Folder1",
|
||||
"C:\\Fixture\\Folder2",
|
||||
"C:\\Fixture\\Folder3"
|
||||
],
|
||||
"socks5ProxyEndpoint": "proxy.example.test:1080",
|
||||
"supportedProtocols": [
|
||||
"TCP",
|
||||
"UDP"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"logLevel": "Debug",
|
||||
"bypassLan": false,
|
||||
"customRootField": "REDACTED",
|
||||
"proxies": [
|
||||
{
|
||||
"appNames": [
|
||||
"FixtureProcess"
|
||||
],
|
||||
"socks5ProxyEndpoint": "proxy.example.test:1080",
|
||||
"supportedProtocols": [
|
||||
"TCP",
|
||||
"ICMP"
|
||||
],
|
||||
"username": "__REDACTED_USERNAME__",
|
||||
"password": "__REDACTED_PASSWORD__",
|
||||
"tls": {
|
||||
"enabled": true,
|
||||
"serverName": "tls.example.test"
|
||||
},
|
||||
"addressFamily": "IPv4",
|
||||
"customProxyField": "REDACTED"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
use proxywarden_lib::helper::{
|
||||
helper_action_requires_elevation, install_request, parse_helper_response,
|
||||
proxifyre_apply_request, service_request, HelperAction, HelperCommandOutput,
|
||||
HelperCommandRunner, HelperCommandSpec, HelperError, HelperResponse, StructuredHelper,
|
||||
};
|
||||
use proxywarden_lib::models::ComponentId;
|
||||
use serde_json::json;
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn structured_helper_serializes_request_and_parses_json_response() {
|
||||
let runner = MockRunner {
|
||||
output: HelperCommandOutput {
|
||||
status_code: 0,
|
||||
stdout: serde_json::to_string(&HelperResponse {
|
||||
success: true,
|
||||
action: HelperAction::ProxyfierApply,
|
||||
changed: true,
|
||||
message: "Applied".to_string(),
|
||||
details: json!({ "serviceName": "ProxiFyreService" }),
|
||||
})
|
||||
.expect("response json"),
|
||||
stderr: String::new(),
|
||||
},
|
||||
seen: RefCell::new(Vec::new()),
|
||||
};
|
||||
let helper = StructuredHelper::new("proxywarden-helper.exe", runner);
|
||||
|
||||
let response = helper
|
||||
.execute(&proxifyre_apply_request(
|
||||
r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json",
|
||||
"ProxiFyreService",
|
||||
))
|
||||
.expect("helper response");
|
||||
|
||||
assert!(response.success);
|
||||
assert_eq!(response.action, HelperAction::ProxyfierApply);
|
||||
assert_eq!(response.details["serviceName"], "ProxiFyreService");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_runner_receives_json_stdin_and_elevation_flag() {
|
||||
let runner = MockRunner {
|
||||
output: HelperCommandOutput {
|
||||
status_code: 0,
|
||||
stdout: r#"{"success":true,"action":"service.restart","changed":true,"message":"Restarted","details":{}}"#.to_string(),
|
||||
stderr: String::new(),
|
||||
},
|
||||
seen: RefCell::new(Vec::new()),
|
||||
};
|
||||
let helper = StructuredHelper::new("proxywarden-helper.exe", runner);
|
||||
let request = service_request(ComponentId::Proxyfier, HelperAction::ServiceRestart);
|
||||
|
||||
let _ = helper.execute(&request).expect("helper response");
|
||||
let seen = helper.runner().seen.borrow();
|
||||
let spec = seen.first().expect("runner should be called");
|
||||
let stdin: serde_json::Value = serde_json::from_str(&spec.stdin).expect("stdin json");
|
||||
|
||||
assert_eq!(spec.program, PathBuf::from("proxywarden-helper.exe"));
|
||||
assert_eq!(spec.args, vec!["--json"]);
|
||||
assert!(spec.requires_elevation);
|
||||
assert_eq!(stdin["action"], "service.restart");
|
||||
assert_eq!(stdin["component"], "proxyfier");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_requests_are_explicit_component_actions() {
|
||||
let control = install_request(ComponentId::ControlApp);
|
||||
let proxyfier = install_request(ComponentId::Proxyfier);
|
||||
let singbox = install_request(ComponentId::Singbox);
|
||||
|
||||
assert_eq!(control.action, HelperAction::InstallControlApp);
|
||||
assert_eq!(proxyfier.action, HelperAction::InstallProxyfier);
|
||||
assert_eq!(singbox.action, HelperAction::InstallSingbox);
|
||||
assert!(helper_action_requires_elevation(&proxyfier.action));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_request_does_not_encode_installer_action() {
|
||||
let request = proxifyre_apply_request(
|
||||
r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json",
|
||||
"ProxiFyreService",
|
||||
);
|
||||
|
||||
assert_eq!(request.action, HelperAction::ProxyfierApply);
|
||||
assert_eq!(request.component, Some(ComponentId::Proxyfier));
|
||||
assert_eq!(
|
||||
request.payload["configPath"],
|
||||
r"C:\ProgramData\ProxyWarden\generated\proxifyre-app-config.json"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_json_helper_stdout_is_rejected() {
|
||||
let error = parse_helper_response("Proxyfier restarted successfully")
|
||||
.expect_err("raw stdout should not be accepted");
|
||||
|
||||
assert_eq!(error.code, "helper_response_decode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_helper_exit_is_structured_error() {
|
||||
let runner = MockRunner {
|
||||
output: HelperCommandOutput {
|
||||
status_code: 5,
|
||||
stdout: String::new(),
|
||||
stderr: "Access denied".to_string(),
|
||||
},
|
||||
seen: RefCell::new(Vec::new()),
|
||||
};
|
||||
let helper = StructuredHelper::new("proxywarden-helper.exe", runner);
|
||||
let error = helper
|
||||
.execute(&service_request(
|
||||
ComponentId::Proxyfier,
|
||||
HelperAction::ServiceRestart,
|
||||
))
|
||||
.expect_err("failed exit should become helper error");
|
||||
|
||||
assert_eq!(error.code, "helper_exit");
|
||||
assert!(error.message.contains("Access denied"));
|
||||
}
|
||||
|
||||
struct MockRunner {
|
||||
output: HelperCommandOutput,
|
||||
seen: RefCell<Vec<HelperCommandSpec>>,
|
||||
}
|
||||
|
||||
impl HelperCommandRunner for MockRunner {
|
||||
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError> {
|
||||
self.seen.borrow_mut().push(spec.clone());
|
||||
Ok(self.output.clone())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use proxywarden_lib::command_dto::CommandError;
|
||||
use proxywarden_lib::component_inventory::{
|
||||
classify_component_candidates, BinaryIdentityEvidence, CandidateRole, ComponentCandidateProbe,
|
||||
InventoryAction, MarkerEvidence, ServiceEvidence, OWNERSHIP_MISMATCH,
|
||||
};
|
||||
use proxywarden_lib::models::ComponentId;
|
||||
use proxywarden_lib::proxifyre_runtime::run_proxifyre_lifecycle_entrypoint;
|
||||
use proxywarden_lib::singbox_runtime::{
|
||||
run_singbox_config_check_entrypoint, run_singbox_lifecycle_entrypoint,
|
||||
};
|
||||
use std::cell::Cell;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn foreign_component_blocks_real_proxifyre_lifecycle_runners_before_call() {
|
||||
let inventory = foreign_inventory();
|
||||
|
||||
for action in [
|
||||
InventoryAction::Install,
|
||||
InventoryAction::Apply,
|
||||
InventoryAction::CheckBinary,
|
||||
InventoryAction::Start,
|
||||
InventoryAction::Stop,
|
||||
InventoryAction::ConfigureFirewall,
|
||||
InventoryAction::Update,
|
||||
InventoryAction::Uninstall,
|
||||
] {
|
||||
let calls = Cell::new(0_u32);
|
||||
let result = run_proxifyre_lifecycle_entrypoint(&inventory, action, |_| {
|
||||
calls.set(calls.get() + 1);
|
||||
Ok::<_, CommandError>(())
|
||||
});
|
||||
|
||||
assert_eq!(calls.get(), 0, "runner was called for {action:?}");
|
||||
assert_eq!(result.unwrap_err().code, OWNERSHIP_MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_component_blocks_real_singbox_process_service_and_delete_runners() {
|
||||
let inventory = incomplete_inventory();
|
||||
|
||||
for action in [
|
||||
InventoryAction::Install,
|
||||
InventoryAction::Apply,
|
||||
InventoryAction::CheckBinary,
|
||||
InventoryAction::Start,
|
||||
InventoryAction::Stop,
|
||||
InventoryAction::Uninstall,
|
||||
] {
|
||||
let calls = Cell::new(0_u32);
|
||||
let result = run_singbox_lifecycle_entrypoint(&inventory, action, |_| {
|
||||
calls.set(calls.get() + 1);
|
||||
Ok::<_, CommandError>(())
|
||||
});
|
||||
|
||||
assert_eq!(calls.get(), 0, "runner was called for {action:?}");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
let process_calls = Cell::new(0_u32);
|
||||
let result = run_singbox_config_check_entrypoint(&inventory, |_| {
|
||||
process_calls.set(process_calls.get() + 1);
|
||||
Ok::<_, CommandError>(())
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(process_calls.get(), 0, "sing-box checker was called");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_component_allows_install_runner_only() {
|
||||
let inventory = classify_component_candidates(ComponentId::Singbox, Vec::new());
|
||||
let calls = Cell::new(0_u32);
|
||||
|
||||
run_singbox_lifecycle_entrypoint(&inventory, InventoryAction::Install, |candidate| {
|
||||
assert!(candidate.is_none());
|
||||
calls.set(calls.get() + 1);
|
||||
Ok::<_, CommandError>(())
|
||||
})
|
||||
.expect("missing component should permit explicit install");
|
||||
|
||||
assert_eq!(calls.get(), 1);
|
||||
}
|
||||
|
||||
fn foreign_inventory() -> proxywarden_lib::component_inventory::ComponentInventory {
|
||||
let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre");
|
||||
classify_component_candidates(
|
||||
ComponentId::Proxyfier,
|
||||
vec![ComponentCandidateProbe {
|
||||
component_id: ComponentId::Proxyfier,
|
||||
role: CandidateRole::Current,
|
||||
root: root.clone(),
|
||||
root_exists: true,
|
||||
has_reparse_point: false,
|
||||
executable_path: Some(root.join("ProxiFyre.exe")),
|
||||
missing_files: Vec::new(),
|
||||
marker: MarkerEvidence::Valid,
|
||||
marker_required: true,
|
||||
binary_identity: BinaryIdentityEvidence::KnownPackage,
|
||||
binary_version: Some("2.4.0.0".to_string()),
|
||||
service: Some(ServiceEvidence {
|
||||
name: "ProxiFyreService".to_string(),
|
||||
status: "running".to_string(),
|
||||
path_name: Some(r#""C:\Foreign\ProxiFyre.exe" --service"#.to_string()),
|
||||
executable_path: Some(PathBuf::from(r"C:\Foreign\ProxiFyre.exe")),
|
||||
path_matches_candidate: false,
|
||||
binary_version: None,
|
||||
}),
|
||||
service_required: true,
|
||||
legacy_identity_complete: false,
|
||||
}],
|
||||
)
|
||||
}
|
||||
|
||||
fn incomplete_inventory() -> proxywarden_lib::component_inventory::ComponentInventory {
|
||||
let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\sing-box");
|
||||
classify_component_candidates(
|
||||
ComponentId::Singbox,
|
||||
vec![ComponentCandidateProbe {
|
||||
component_id: ComponentId::Singbox,
|
||||
role: CandidateRole::Current,
|
||||
root: root.clone(),
|
||||
root_exists: true,
|
||||
has_reparse_point: false,
|
||||
executable_path: Some(root.join("sing-box.exe")),
|
||||
missing_files: vec![root.join("ProxyWardenSingBox.exe")],
|
||||
marker: MarkerEvidence::NotRequired,
|
||||
marker_required: false,
|
||||
binary_identity: BinaryIdentityEvidence::Unknown,
|
||||
binary_version: None,
|
||||
service: None,
|
||||
service_required: true,
|
||||
legacy_identity_complete: false,
|
||||
}],
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,346 @@
|
||||
use proxywarden_lib::component_catalog::ComponentId;
|
||||
use proxywarden_lib::component_packages::{
|
||||
ComponentPackageService, ComponentPackagesError, GithubReleaseDigestProof,
|
||||
NativePrivilegedBundleVerifier, PackageSignatureVerifier, PackageSource,
|
||||
PrivilegedBundleVerificationError, PrivilegedBundleVerifier, PrivilegedCachedUpdatePlan,
|
||||
SignaturePublisher, SignatureVerifierError, UpdateRequestKind, UpdateTransport,
|
||||
UpdateTransportError, UpdateTransportRequest, UpdateTransportResponse,
|
||||
};
|
||||
use proxywarden_lib::safe_fs::protect_path_for_owner_admin_system;
|
||||
use proxywarden_lib::storage::StoragePaths;
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn privileged_fresh_install_ignores_forged_newer_cache_and_has_no_transport() {
|
||||
let workspace = TestWorkspace::new();
|
||||
let paths = workspace.storage_paths();
|
||||
let forged = paths
|
||||
.packages_dir
|
||||
.join(ComponentId::SingBox.as_str())
|
||||
.join("999.0.0");
|
||||
fs::create_dir_all(&forged).expect("create forged cache");
|
||||
fs::write(forged.join("forged.zip"), b"not an official package").expect("write forged cache");
|
||||
let service =
|
||||
ComponentPackageService::open(bundled_root(), &paths).expect("open component packages");
|
||||
let verifier = ExplicitTestBundleVerifier::default();
|
||||
|
||||
let lease = service
|
||||
.lease_bundled_for_privileged_install(ComponentId::SingBox, &verifier)
|
||||
.expect("lease immutable bundled sing-box");
|
||||
|
||||
assert_eq!(lease.proof().source, PackageSource::Bundled);
|
||||
assert_eq!(lease.proof().version, "1.13.19");
|
||||
assert_eq!(lease.proof().independent_proof, None);
|
||||
assert_eq!(verifier.calls.load(Ordering::SeqCst), 1);
|
||||
assert!(lease.asset_path().starts_with(bundled_root()));
|
||||
// There is intentionally no transport argument on the fresh-install API.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_bundle_verifier_rejects_user_owned_or_noncanonical_bundle() {
|
||||
let workspace = TestWorkspace::new();
|
||||
let service = ComponentPackageService::open(bundled_root(), &workspace.storage_paths())
|
||||
.expect("open component packages");
|
||||
|
||||
assert!(matches!(
|
||||
service.lease_bundled_for_privileged_install(
|
||||
ComponentId::SingBox,
|
||||
&NativePrivilegedBundleVerifier
|
||||
),
|
||||
Err(ComponentPackagesError::UntrustedBundleRoot)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privileged_update_requires_the_exact_live_latest_proof_before_staging() {
|
||||
let workspace = TestWorkspace::new();
|
||||
let service = ComponentPackageService::open(bundled_root(), &workspace.storage_paths())
|
||||
.expect("open component packages");
|
||||
let plan = sing_box_plan(b"cached sing-box update");
|
||||
let mut wrong_latest = plan.clone();
|
||||
wrong_latest.independent_proof.release_id += 1;
|
||||
let transport = MetadataTransport::success(metadata_for(&wrong_latest));
|
||||
|
||||
assert!(matches!(
|
||||
service.lease_cached_update_for_privileged_install(
|
||||
&plan,
|
||||
workspace.path(),
|
||||
&transport,
|
||||
&NoopVerifier,
|
||||
),
|
||||
Err(ComponentPackagesError::InvalidPrivilegedUpdatePlan)
|
||||
));
|
||||
assert_eq!(transport.calls.load(Ordering::SeqCst), 1);
|
||||
assert_no_privileged_staging(workspace.path());
|
||||
|
||||
let timeout = MetadataTransport::failure();
|
||||
assert!(matches!(
|
||||
service.lease_cached_update_for_privileged_install(
|
||||
&plan,
|
||||
workspace.path(),
|
||||
&timeout,
|
||||
&NoopVerifier,
|
||||
),
|
||||
Err(ComponentPackagesError::Transport)
|
||||
));
|
||||
assert_eq!(timeout.calls.load(Ordering::SeqCst), 1);
|
||||
assert_no_privileged_staging(workspace.path());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn tampered_cache_fails_before_any_admin_staging_is_created() {
|
||||
let workspace = TestWorkspace::new();
|
||||
let paths = workspace.storage_paths();
|
||||
let service =
|
||||
ComponentPackageService::open(bundled_root(), &paths).expect("open component packages");
|
||||
let plan = sing_box_plan(b"official update bytes");
|
||||
write_cache(&paths, &plan, b"tampered cache bytes");
|
||||
let transport = MetadataTransport::success(metadata_for(&plan));
|
||||
|
||||
assert!(matches!(
|
||||
service.lease_cached_update_for_privileged_install(
|
||||
&plan,
|
||||
workspace.path(),
|
||||
&transport,
|
||||
&NoopVerifier,
|
||||
),
|
||||
Err(ComponentPackagesError::DigestMismatch)
|
||||
));
|
||||
assert_eq!(transport.calls.load(Ordering::SeqCst), 1);
|
||||
assert_no_privileged_staging(workspace.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winsw_and_vc_runtime_make_zero_privileged_update_transport_calls() {
|
||||
let workspace = TestWorkspace::new();
|
||||
let service = ComponentPackageService::open(bundled_root(), &workspace.storage_paths())
|
||||
.expect("open component packages");
|
||||
|
||||
for component_id in [ComponentId::Winsw, ComponentId::VcRuntime] {
|
||||
let transport = MetadataTransport::failure();
|
||||
let mut plan = sing_box_plan(b"unused");
|
||||
plan.component_id = component_id;
|
||||
assert!(matches!(
|
||||
service.lease_cached_update_for_privileged_install(
|
||||
&plan,
|
||||
workspace.path(),
|
||||
&transport,
|
||||
&NoopVerifier,
|
||||
),
|
||||
Err(ComponentPackagesError::NoTrustedUpdate)
|
||||
));
|
||||
assert_eq!(transport.calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ExplicitTestBundleVerifier {
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
struct NoopVerifier;
|
||||
|
||||
impl PackageSignatureVerifier for NoopVerifier {
|
||||
fn verify(&self, _path: &Path) -> Result<SignaturePublisher, SignatureVerifierError> {
|
||||
Err(SignatureVerifierError)
|
||||
}
|
||||
}
|
||||
|
||||
impl PrivilegedBundleVerifier for ExplicitTestBundleVerifier {
|
||||
fn verify(
|
||||
&self,
|
||||
bundled_root: &Path,
|
||||
catalog_path: &Path,
|
||||
asset_path: &Path,
|
||||
) -> Result<(), PrivilegedBundleVerificationError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if catalog_path != bundled_root.join("catalog.json")
|
||||
|| !asset_path.starts_with(bundled_root)
|
||||
{
|
||||
return Err(PrivilegedBundleVerificationError);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct MetadataTransport {
|
||||
response: Result<Vec<u8>, UpdateTransportError>,
|
||||
calls: AtomicUsize,
|
||||
}
|
||||
|
||||
impl MetadataTransport {
|
||||
fn success(response: Vec<u8>) -> Self {
|
||||
Self {
|
||||
response: Ok(response),
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure() -> Self {
|
||||
Self {
|
||||
response: Err(UpdateTransportError::RequestFailed),
|
||||
calls: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UpdateTransport for MetadataTransport {
|
||||
fn get(
|
||||
&self,
|
||||
request: &UpdateTransportRequest,
|
||||
) -> Result<UpdateTransportResponse, UpdateTransportError> {
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
assert_eq!(request.kind, UpdateRequestKind::GithubReleaseMetadata);
|
||||
assert_eq!(
|
||||
request.url,
|
||||
"https://api.github.com/repos/SagerNet/sing-box/releases/latest"
|
||||
);
|
||||
let body = self.response.clone()?;
|
||||
Ok(UpdateTransportResponse {
|
||||
status: 200,
|
||||
location: None,
|
||||
content_length: Some(body.len() as u64),
|
||||
body: Box::new(Cursor::new(body)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn sing_box_plan(bytes: &[u8]) -> PrivilegedCachedUpdatePlan {
|
||||
let version = "1.14.0";
|
||||
let asset_name = format!("sing-box-{version}-windows-amd64.zip");
|
||||
let sha256 = format!("{:x}", Sha256::digest(bytes));
|
||||
PrivilegedCachedUpdatePlan {
|
||||
component_id: ComponentId::SingBox,
|
||||
version: version.to_string(),
|
||||
independent_proof: GithubReleaseDigestProof {
|
||||
repository: "SagerNet/sing-box".to_string(),
|
||||
release_id: 700,
|
||||
asset_id: 701,
|
||||
stable_tag: format!("v{version}"),
|
||||
asset_name,
|
||||
size: bytes.len() as u64,
|
||||
sha256_from_api: sha256,
|
||||
verified_signatures: Vec::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_for(plan: &PrivilegedCachedUpdatePlan) -> Vec<u8> {
|
||||
let proof = &plan.independent_proof;
|
||||
serde_json::to_vec(&json!({
|
||||
"id": proof.release_id,
|
||||
"tag_name": proof.stable_tag,
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"assets": [{
|
||||
"id": proof.asset_id,
|
||||
"name": proof.asset_name,
|
||||
"size": proof.size,
|
||||
"digest": format!("sha256:{}", proof.sha256_from_api),
|
||||
"browser_download_url": format!(
|
||||
"https://github.com/{}/releases/download/{}/{}",
|
||||
proof.repository, proof.stable_tag, proof.asset_name
|
||||
)
|
||||
}]
|
||||
}))
|
||||
.expect("serialize GitHub metadata")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn write_cache(paths: &StoragePaths, plan: &PrivilegedCachedUpdatePlan, bytes: &[u8]) {
|
||||
use proxywarden_lib::component_packages::{
|
||||
PackageCacheManifest, PACKAGE_CACHE_MANIFEST_FILENAME,
|
||||
PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
let component_root = paths.packages_dir.join(plan.component_id.as_str());
|
||||
let version_root = component_root.join(&plan.version);
|
||||
fs::create_dir_all(&version_root).expect("create cache directory");
|
||||
let asset_path = version_root.join(&plan.independent_proof.asset_name);
|
||||
fs::write(&asset_path, bytes).expect("write cache bytes");
|
||||
let manifest_path = version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME);
|
||||
let manifest = PackageCacheManifest {
|
||||
schema_version: PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION,
|
||||
component_id: plan.component_id,
|
||||
version: plan.version.clone(),
|
||||
asset_name: plan.independent_proof.asset_name.clone(),
|
||||
sha256: plan.independent_proof.sha256_from_api.clone(),
|
||||
size: plan.independent_proof.size,
|
||||
independent_proof: plan.independent_proof.clone(),
|
||||
};
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
serde_json::to_vec_pretty(&manifest).expect("serialize cache manifest"),
|
||||
)
|
||||
.expect("write cache manifest");
|
||||
for path in [
|
||||
paths.packages_dir.as_path(),
|
||||
component_root.as_path(),
|
||||
version_root.as_path(),
|
||||
manifest_path.as_path(),
|
||||
asset_path.as_path(),
|
||||
] {
|
||||
protect_path_for_owner_admin_system(path).expect("protect cache path");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_no_privileged_staging(parent: &Path) {
|
||||
let staging = fs::read_dir(parent)
|
||||
.expect("read staging parent")
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| entry.file_name().into_string().ok())
|
||||
.filter(|name| name.starts_with(".package-"))
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
staging.is_empty(),
|
||||
"unexpected staging entries: {staging:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn bundled_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("bundled")
|
||||
.join("components")
|
||||
}
|
||||
|
||||
struct TestWorkspace {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl TestWorkspace {
|
||||
fn new() -> Self {
|
||||
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("target");
|
||||
fs::create_dir_all(&target).expect("create Cargo target directory");
|
||||
let path = target.join(format!("privileged-package-trust-{}", Uuid::new_v4()));
|
||||
fs::create_dir(&path).expect("create test workspace");
|
||||
Self { path }
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn storage_paths(&self) -> StoragePaths {
|
||||
StoragePaths::new(&self.path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestWorkspace {
|
||||
fn drop(&mut self) {
|
||||
if self
|
||||
.path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("privileged-package-trust-"))
|
||||
{
|
||||
let _ = fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -86,6 +86,49 @@ fn skips_singbox_check_when_binary_path_is_not_supplied() {
|
||||
assert!(checker.calls.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_endpoint_uses_exact_outbound_id_and_never_falls_back_from_missing_id() {
|
||||
let parsed = proxywarden_lib::subscription::parse_subscription_body(r#"{"outbounds":[
|
||||
{"type":"vless","tag":"same%20label","server":"edge.example.test","server_port":443,"uuid":"11111111-1111-1111-1111-111111111111"},
|
||||
{"type":"vless","tag":"same%20label","server":"edge.example.test","server_port":443,"uuid":"22222222-2222-2222-2222-222222222222"}
|
||||
]}"#).unwrap();
|
||||
let mut cache = SubscriptionCache {
|
||||
config: parsed.config,
|
||||
servers: parsed.servers,
|
||||
user_info: Default::default(),
|
||||
fetched_at: "fixture".into(),
|
||||
};
|
||||
let mut config = local_singbox_config("same label");
|
||||
config.selected_server_id = Some(cache.servers[1].id.clone());
|
||||
cache.normalize_percent_encoded_tags();
|
||||
let adapter = SingBoxAdapter::default();
|
||||
let generated = adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&RecordingChecker::ok("fixture"),
|
||||
)
|
||||
.unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&generated.contents).unwrap();
|
||||
assert_eq!(
|
||||
value["outbounds"][0]["uuid"],
|
||||
"22222222-2222-2222-2222-222222222222"
|
||||
);
|
||||
config.selected_server_id = Some("pw-missing".into());
|
||||
assert!(adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&RecordingChecker::ok("fixture")
|
||||
)
|
||||
.is_err());
|
||||
config.selected_server_id = None;
|
||||
assert!(adapter
|
||||
.generate_config(
|
||||
SingBoxGenerationRequest::new(&config, &cache, None),
|
||||
&RecordingChecker::ok("fixture")
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_config_when_server_is_not_selected() {
|
||||
let adapter = SingBoxAdapter::default();
|
||||
|
||||
@@ -11,6 +11,8 @@ use proxywarden_lib::models::{
|
||||
ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, TargetKind,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use proxywarden_lib::safe_fs;
|
||||
use proxywarden_lib::storage::JsonStorage;
|
||||
use proxywarden_lib::subscription;
|
||||
use serde_json::{json, Map};
|
||||
@@ -342,6 +344,11 @@ fn generate_writes_config_and_local_singbox_target() {
|
||||
assert_eq!(target.port, 1080);
|
||||
assert_eq!(target.requires_component, Some(ComponentId::Singbox));
|
||||
assert_eq!(activity[0].title, "Конфиг Local sing-box создан");
|
||||
#[cfg(windows)]
|
||||
safe_fs::verify_path_protected_for_owner_admin_system(Path::new(
|
||||
&response.generated_config_path,
|
||||
))
|
||||
.expect("generated sing-box config keeps restricted ACL");
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -526,6 +533,111 @@ fn sample_cache() -> SubscriptionCache {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_candidate_fetch_keeps_the_previous_url_cache_and_selection() {
|
||||
struct FailedFetcher;
|
||||
impl SubscriptionFetcher for FailedFetcher {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
Err(subscription::SubscriptionError {
|
||||
message: "offline".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
let root = test_root("candidate-failure");
|
||||
let storage = JsonStorage::new(&root);
|
||||
save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "https://old.example.test/token".into(),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.unwrap();
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache())
|
||||
.unwrap();
|
||||
let before = fs::read(&storage.paths().local_singbox_file).unwrap();
|
||||
let cache_before = fs::read(&storage.paths().singbox_subscription_cache_file).unwrap();
|
||||
assert!(
|
||||
proxywarden_lib::singbox_subscription::fetch_singbox_subscription_candidate(
|
||||
&storage,
|
||||
Some("https://new.example.test/token"),
|
||||
&FailedFetcher,
|
||||
&FixedClock
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&storage.paths().local_singbox_file).unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&storage.paths().singbox_subscription_cache_file).unwrap(),
|
||||
cache_before
|
||||
);
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_finishing_after_forget_cannot_resurrect_subscription_or_backup() {
|
||||
struct ForgetDuringFetch<'a>(&'a JsonStorage);
|
||||
impl SubscriptionFetcher for ForgetDuringFetch<'_> {
|
||||
fn fetch_subscription(
|
||||
&self,
|
||||
_: &str,
|
||||
_: &subscription::SubscriptionFetchIdentity,
|
||||
) -> Result<SubscriptionCache, subscription::SubscriptionError> {
|
||||
forget_singbox_subscription_in_storage(self.0, &FixedClock).unwrap();
|
||||
Ok(sample_cache())
|
||||
}
|
||||
}
|
||||
let root = test_root("forget-during-fetch");
|
||||
let storage = JsonStorage::new(&root);
|
||||
save_singbox_subscription_to_storage(
|
||||
&storage,
|
||||
SaveSingBoxSubscriptionInputDto {
|
||||
subscription_url: "https://old.example.test/token".into(),
|
||||
},
|
||||
&FixedClock,
|
||||
)
|
||||
.unwrap();
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache())
|
||||
.unwrap();
|
||||
storage
|
||||
.write_singbox_subscription_cache(&sample_cache())
|
||||
.unwrap();
|
||||
let error = fetch_singbox_subscription_with_fetcher(
|
||||
&storage,
|
||||
&ForgetDuringFetch(&storage),
|
||||
&FixedClock,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.code, "configuration_changed");
|
||||
assert!(storage
|
||||
.read_local_singbox_config()
|
||||
.unwrap()
|
||||
.subscription_url
|
||||
.is_none());
|
||||
assert!(storage.read_singbox_subscription_cache().unwrap().is_none());
|
||||
assert!(!proxywarden_lib::safe_fs::backup_path(&storage.paths().local_singbox_file).exists());
|
||||
assert!(!proxywarden_lib::safe_fs::backup_path(
|
||||
&storage.paths().singbox_subscription_cache_file
|
||||
)
|
||||
.exists());
|
||||
fs::remove_file(&storage.paths().local_singbox_file).unwrap();
|
||||
assert!(storage
|
||||
.read_local_singbox_config()
|
||||
.unwrap()
|
||||
.subscription_url
|
||||
.is_none());
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
fn sample_cache_with_flag_tag() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: json!({
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
use proxywarden_lib::component_detection::DetectedSingBox;
|
||||
use proxywarden_lib::singbox_service::{
|
||||
build_singbox_setup_status, ensure_safe_singbox_install_dir, parse_service_command_output,
|
||||
service_control_script, SingBoxServiceAction,
|
||||
build_singbox_setup_status, singbox_service_xml, SINGBOX_SERVICE_LOG_DIR,
|
||||
};
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(windows)]
|
||||
use std::process::Command as ProcessCommand;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn setup_status_reports_missing_items_when_singbox_is_absent() {
|
||||
@@ -28,90 +25,22 @@ fn setup_status_reports_ready_when_binary_wrapper_and_service_exist() {
|
||||
assert!(status.ready);
|
||||
assert_eq!(status.missing_count, 0);
|
||||
assert!(status.items.iter().all(|item| item.installed));
|
||||
assert_eq!(status.items[0].version, Some("1.11.0.0".to_string()));
|
||||
assert_eq!(status.items[1].version, Some("3.0.0.0".to_string()));
|
||||
assert_eq!(status.items[2].version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_last_json_service_command_output_line() {
|
||||
let output = br#"
|
||||
noise
|
||||
{"success":true,"code":"started","serviceName":"ProxyWardenSingBox","status":"Running","processId":42}
|
||||
"#;
|
||||
let parsed = parse_service_command_output(output).expect("service json should parse");
|
||||
fn winsw_disables_logs_and_targets_fixed_app_root_log_directory() {
|
||||
let xml = singbox_service_xml();
|
||||
|
||||
assert!(parsed.success);
|
||||
assert_eq!(parsed.code, "started");
|
||||
assert_eq!(parsed.service_name, Some("ProxyWardenSingBox".to_string()));
|
||||
assert_eq!(parsed.status, Some("Running".to_string()));
|
||||
assert_eq!(parsed.process_id, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_install_dir_allows_only_proxywarden_singbox_folder() {
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(
|
||||
r"C:\Program Files\ProxyWarden\components\sing-box"
|
||||
))
|
||||
.is_ok());
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Windows")).is_err());
|
||||
assert!(ensure_safe_singbox_install_dir(Path::new(r"C:\Program Files\sing-box")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_control_script_targets_named_service_and_action() {
|
||||
let script = service_control_script(
|
||||
SingBoxServiceAction::Start,
|
||||
"ProxyWardenSingBox",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(script.contains("$serviceName = 'ProxyWardenSingBox'"));
|
||||
assert!(script.contains("$action = 'start'"));
|
||||
assert!(script.contains("ConvertTo-Json -Compress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_control_script_syncs_generated_config_before_start() {
|
||||
let source = Path::new(r"C:\ProgramData\ProxyWarden\generated\sing-box-config.json");
|
||||
let target = Path::new(r"C:\Program Files\ProxyWarden\components\sing-box\config.json");
|
||||
let script = service_control_script(
|
||||
SingBoxServiceAction::Start,
|
||||
"ProxyWardenSingBox",
|
||||
Some(source),
|
||||
Some(target),
|
||||
);
|
||||
|
||||
assert!(script.contains(
|
||||
"$configSource = 'C:\\ProgramData\\ProxyWarden\\generated\\sing-box-config.json'"
|
||||
));
|
||||
assert!(script.contains(
|
||||
"$configTarget = 'C:\\Program Files\\ProxyWarden\\components\\sing-box\\config.json'"
|
||||
));
|
||||
assert!(script.contains("Copy-Item -LiteralPath $configSource"));
|
||||
assert!(script.contains("'config_sync_failed'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn install_singbox_script_parses_as_powershell() {
|
||||
let script_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("scripts")
|
||||
.join("install-singbox.ps1");
|
||||
let escaped_path = script_path.display().to_string().replace('\'', "''");
|
||||
let parser = format!(
|
||||
"$tokens = $null; $errors = $null; [System.Management.Automation.Language.Parser]::ParseFile('{escaped_path}', [ref]$tokens, [ref]$errors) | Out-Null; if ($errors.Count -gt 0) {{ $errors | ForEach-Object {{ $_.Message }}; exit 1 }}"
|
||||
);
|
||||
let output = ProcessCommand::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &parser])
|
||||
.output()
|
||||
.expect("powershell parser should run");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"install-singbox.ps1 should parse\nstdout:\n{}\nstderr:\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr),
|
||||
assert!(xml.contains("<log mode=\"none\"/>"));
|
||||
assert!(xml.contains(&format!("<logpath>{SINGBOX_SERVICE_LOG_DIR}</logpath>")));
|
||||
assert_eq!(
|
||||
SINGBOX_SERVICE_LOG_DIR,
|
||||
r"%BASE%\..\..\.proxywarden-service-logs\sing-box"
|
||||
);
|
||||
assert!(!xml.contains("ProgramData"));
|
||||
}
|
||||
|
||||
fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) -> DetectedSingBox {
|
||||
@@ -127,5 +56,7 @@ fn detected_singbox(binary_exists: bool, wrapper_exists: bool, running: bool) ->
|
||||
wrapper_exists,
|
||||
running,
|
||||
service_name: "ProxyWardenSingBox".to_string(),
|
||||
version: Some("1.11.0.0".to_string()),
|
||||
wrapper_version: Some("3.0.0.0".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use proxywarden_lib::models::{
|
||||
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
|
||||
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, SubscriptionCache,
|
||||
SubscriptionServer, Target, TargetKind,
|
||||
ActivityEntry, ActivityLevel, LocalSingBoxConfig, Profile, ProfileItem, ProfileItemType,
|
||||
Protocol, ProxyProtocol, SubscriptionCache, SubscriptionServer, Target, TargetKind,
|
||||
};
|
||||
use proxywarden_lib::storage::{backup_path, default_config_root, JsonStorage, StoragePaths};
|
||||
use std::fs;
|
||||
@@ -17,13 +16,12 @@ fn storage_defaults_to_programdata_root() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips_profiles_targets_components_and_activity() {
|
||||
fn roundtrips_profiles_targets_and_activity() {
|
||||
let root = test_root("roundtrip");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
|
||||
let profiles = vec![sample_profile("discord")];
|
||||
let targets = vec![sample_target("home-gateway")];
|
||||
let components = vec![sample_component()];
|
||||
let activity = vec![sample_activity(
|
||||
"created",
|
||||
"2026-01-01T10:00:00Z",
|
||||
@@ -32,15 +30,10 @@ fn roundtrips_profiles_targets_components_and_activity() {
|
||||
|
||||
storage.write_profiles(&profiles).expect("write profiles");
|
||||
storage.write_targets(&targets).expect("write targets");
|
||||
write_json(&storage.paths().components_file, &components);
|
||||
write_json(&storage.paths().activity_file, &activity);
|
||||
|
||||
assert_eq!(storage.read_profiles().expect("read profiles"), profiles);
|
||||
assert_eq!(storage.read_targets().expect("read targets"), targets);
|
||||
assert_eq!(
|
||||
storage.read_components().expect("read components"),
|
||||
components
|
||||
);
|
||||
assert_eq!(storage.read_activity().expect("read activity"), activity);
|
||||
|
||||
cleanup(&root);
|
||||
@@ -86,6 +79,9 @@ fn roundtrips_local_singbox_config_and_subscription_cache() {
|
||||
config.subscription_display_url(),
|
||||
Some("https://sub.example.test/...".to_string())
|
||||
);
|
||||
let persisted = fs::read_to_string(&storage.paths().local_singbox_file)
|
||||
.expect("read persisted local sing-box config");
|
||||
assert!(!persisted.contains("install_root"));
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
@@ -155,13 +151,13 @@ fn reads_percent_encoded_singbox_tags_as_utf8() {
|
||||
|
||||
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);
|
||||
assert_eq!(cache.config["outbounds"][0]["tag"], encoded_tag);
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_subscription_cache_without_backup_returns_error_and_moves_corrupt_file() {
|
||||
fn invalid_subscription_cache_without_backup_preserves_corruption_across_repeated_reads() {
|
||||
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");
|
||||
@@ -176,16 +172,14 @@ fn invalid_subscription_cache_without_backup_returns_error_and_moves_corrupt_fil
|
||||
.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
|
||||
));
|
||||
assert!(storage.paths().singbox_subscription_cache_file.exists());
|
||||
assert!(storage.read_singbox_subscription_cache().is_err());
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() {
|
||||
fn invalid_json_without_backup_preserves_corruption_across_repeated_reads() {
|
||||
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");
|
||||
@@ -196,8 +190,8 @@ fn invalid_json_without_backup_returns_error_and_moves_corrupt_file() {
|
||||
.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));
|
||||
assert!(storage.paths().profiles_file.exists());
|
||||
assert!(storage.read_profiles().is_err());
|
||||
|
||||
cleanup(&root);
|
||||
}
|
||||
@@ -308,6 +302,24 @@ fn cleanup(root: &Path) {
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_primary_restores_valid_backup_but_never_defaults_over_invalid_backup() {
|
||||
let root = test_root("missing-source-backup");
|
||||
let storage = JsonStorage::new(root.clone());
|
||||
let saved = vec![sample_profile("preserved")];
|
||||
storage.write_profiles(&saved).unwrap();
|
||||
storage.write_profiles(&[]).unwrap();
|
||||
fs::remove_file(&storage.paths().profiles_file).unwrap();
|
||||
assert_eq!(storage.read_profiles().unwrap(), saved);
|
||||
assert_eq!(storage.read_profiles().unwrap(), saved);
|
||||
fs::remove_file(&storage.paths().profiles_file).unwrap();
|
||||
fs::write(backup_path(&storage.paths().profiles_file), "{broken").unwrap();
|
||||
assert!(storage.read_profiles().is_err());
|
||||
assert!(storage.read_profiles().is_err());
|
||||
assert!(!storage.paths().profiles_file.exists());
|
||||
cleanup(&root);
|
||||
}
|
||||
|
||||
fn write_json<T: serde::Serialize + ?Sized>(path: &Path, value: &T) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create json parent dir");
|
||||
@@ -363,22 +375,6 @@ fn sample_target(id: &str) -> Target {
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_component() -> ComponentStatus {
|
||||
ComponentStatus {
|
||||
id: ComponentId::Proxyfier,
|
||||
name: "ProxiFyre".to_string(),
|
||||
state: ComponentState::Missing,
|
||||
installed: false,
|
||||
running: false,
|
||||
version: None,
|
||||
path: None,
|
||||
service_name: Some("ProxiFyreService".to_string()),
|
||||
service_status: None,
|
||||
problems: vec!["ProxiFyre не установлен".to_string()],
|
||||
actions: vec!["Установить ProxiFyre".to_string()],
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_subscription_cache() -> SubscriptionCache {
|
||||
SubscriptionCache {
|
||||
config: serde_json::json!({
|
||||
|
||||
Reference in New Issue
Block a user