939 lines
31 KiB
Rust
939 lines
31 KiB
Rust
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()
|
|
}
|