@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user