1326 lines
48 KiB
Rust
1326 lines
48 KiB
Rust
use proxywarden_lib::adapters::singbox::{
|
|
SingBoxAdapter, SingBoxCheckResult, SingBoxConfigChecker, SingBoxConfigError,
|
|
SingBoxGenerationRequest,
|
|
};
|
|
use proxywarden_lib::component_catalog::ComponentId as CatalogComponentId;
|
|
use proxywarden_lib::component_cutover::{
|
|
ComponentCutoverObservation, ComponentCutoverUserEvidence, CutoverDisplayState,
|
|
CutoverExecutionMode, CutoverHostError, CutoverPhase, LegacyServiceState, StateFingerprint,
|
|
CUTOVER_OBSERVATION_SCHEMA_VERSION, CUTOVER_USER_EVIDENCE_SCHEMA_VERSION,
|
|
};
|
|
use proxywarden_lib::component_inventory::{
|
|
component_inventory_fingerprint_for_cutover, CandidateRole, ComponentCandidate,
|
|
ComponentClassification, ComponentInventory, MarkerEvidence, ServiceEvidence,
|
|
};
|
|
use proxywarden_lib::component_packages::{
|
|
ComponentPackageService, ComponentUpdateObservation, ComponentUpdatesState,
|
|
GithubReleaseDigestProof, PackageCacheManifest, PackageSignatureVerifier,
|
|
PrivilegedBundleVerificationError, PrivilegedBundleVerifier, PrivilegedCachedUpdatePlan,
|
|
SignaturePublisher, SignatureVerifierError, TrustedGithubReleaseObservation, UpdateRequestKind,
|
|
UpdateTransport, UpdateTransportError, UpdateTransportRequest, UpdateTransportResponse,
|
|
COMPONENT_UPDATES_STATE_SCHEMA_VERSION, PACKAGE_CACHE_MANIFEST_FILENAME,
|
|
PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION,
|
|
};
|
|
use proxywarden_lib::models::{LocalSingBoxConfig, SubscriptionCache};
|
|
use proxywarden_lib::privileged_jobs::{
|
|
cutover_recovery_probe_fingerprint, ManagedComponent, PrivilegedAction, PrivilegedActionRunner,
|
|
PrivilegedCutoverMode, PrivilegedPlanResolver, PrivilegedRunnerFailure,
|
|
};
|
|
use proxywarden_lib::privileged_runtime::{
|
|
classify_cutover_runtime_config_for_tests, dispatch_candidate_observation_for_tests,
|
|
shared_service_name_path_ownership_for_tests,
|
|
stopped_cutover_route_lifecycle_exception_for_tests, validate_generated_config_bytes_for_tests,
|
|
LocalPrivilegedPlanResolver, SystemPrivilegedRuntime,
|
|
};
|
|
use proxywarden_lib::proxifyre_runtime::{ProxifyreNativeHostError, RuntimeConfigVerification};
|
|
use proxywarden_lib::safe_fs;
|
|
use proxywarden_lib::storage::StoragePaths;
|
|
use proxywarden_lib::subscription::parse_subscription_body;
|
|
use serde_json::{json, Value};
|
|
use sha2::{Digest, Sha256};
|
|
use std::fs;
|
|
use std::io::Cursor;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
use uuid::Uuid;
|
|
|
|
#[test]
|
|
fn current_verification_distinguishes_exact_config_mismatch_from_ambiguous_read_failure() {
|
|
assert_eq!(
|
|
classify_cutover_runtime_config_for_tests(Ok(RuntimeConfigVerification::Match)),
|
|
Ok(())
|
|
);
|
|
assert_eq!(
|
|
classify_cutover_runtime_config_for_tests(Ok(RuntimeConfigVerification::Mismatch)),
|
|
Err(CutoverHostError::VerificationFailed),
|
|
"successfully read mismatching bytes may trigger the sealed rollback path"
|
|
);
|
|
assert_eq!(
|
|
classify_cutover_runtime_config_for_tests(Err(ProxifyreNativeHostError)),
|
|
Err(CutoverHostError::ObservationFailed),
|
|
"ACL, open, read, or revalidation failures remain ambiguous and must not trigger rollback"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn restart_recovery_dispatches_pending_candidate_observation_without_live_package_writer() {
|
|
let live_calls = AtomicUsize::new(0);
|
|
let recovered_calls = AtomicUsize::new(0);
|
|
let expected_fingerprint = StateFingerprint::digest("test:recovered-candidate", b"expected");
|
|
let expected_identity = safe_fs::StableObjectIdentity {
|
|
volume_serial_number: 7,
|
|
file_id: 11,
|
|
kind: safe_fs::StableObjectKind::RegularFile,
|
|
size: 13,
|
|
};
|
|
|
|
let observed = dispatch_candidate_observation_for_tests(
|
|
false,
|
|
|| {
|
|
live_calls.fetch_add(1, Ordering::SeqCst);
|
|
Err(CutoverHostError::ObservationFailed)
|
|
},
|
|
|| {
|
|
recovered_calls.fetch_add(1, Ordering::SeqCst);
|
|
Ok((
|
|
expected_fingerprint.clone(),
|
|
Some(expected_identity.clone()),
|
|
))
|
|
},
|
|
)
|
|
.expect("package-free recovery observation");
|
|
|
|
assert_eq!(live_calls.load(Ordering::SeqCst), 0);
|
|
assert_eq!(recovered_calls.load(Ordering::SeqCst), 1);
|
|
assert_eq!(observed.0, expected_fingerprint);
|
|
assert_eq!(observed.1, Some(expected_identity));
|
|
}
|
|
|
|
#[test]
|
|
fn shared_service_name_is_owned_by_exact_binary_path_not_scm_record_existence() {
|
|
assert_eq!(
|
|
shared_service_name_path_ownership_for_tests(true, false),
|
|
(true, false),
|
|
"the legacy binary owns the shared SCM name before cutover and after rollback"
|
|
);
|
|
assert_eq!(
|
|
shared_service_name_path_ownership_for_tests(false, true),
|
|
(false, true),
|
|
"the current binary owns the same SCM name after cutover"
|
|
);
|
|
assert_eq!(
|
|
shared_service_name_path_ownership_for_tests(false, false),
|
|
(false, false),
|
|
"a missing or foreign path is not silently assigned to either owner"
|
|
);
|
|
assert_eq!(
|
|
shared_service_name_path_ownership_for_tests(true, true),
|
|
(false, false),
|
|
"an ambiguous dual match is never authorized"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn fresh_bundled_plans_are_exact_ordered_and_make_zero_transport_calls() {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
let packages = package_service(&paths);
|
|
let transport = Arc::new(CountingTransport::default());
|
|
let bundle = Arc::new(CountingBundleVerifier::default());
|
|
let runtime = SystemPrivilegedRuntime::from_parts_for_tests(
|
|
Some(packages),
|
|
paths,
|
|
workspace.path().join("staging"),
|
|
bundle.clone(),
|
|
Some(transport.clone()),
|
|
Arc::new(RejectingSignatureVerifier),
|
|
);
|
|
|
|
let proxifyre = runtime
|
|
.resolve(PrivilegedAction::InstallProxifyre)
|
|
.expect("fresh ProxiFyre plan");
|
|
let proxifyre_package = proxifyre.package.expect("ProxiFyre package");
|
|
assert_eq!(proxifyre_package.version, "2.4.0");
|
|
assert_eq!(
|
|
proxifyre_package
|
|
.assets
|
|
.iter()
|
|
.map(|asset| (asset.component_id, asset.version.as_str()))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(CatalogComponentId::Proxifyre, "2.4.0"),
|
|
(CatalogComponentId::WindowsPacketFilter, "3.6.2"),
|
|
(CatalogComponentId::VcRuntime, "14.51.36247.0"),
|
|
]
|
|
);
|
|
|
|
let singbox = runtime
|
|
.resolve(PrivilegedAction::InstallSingBox)
|
|
.expect("fresh sing-box plan");
|
|
let singbox_package = singbox.package.expect("sing-box package");
|
|
assert_eq!(singbox_package.version, "1.13.19");
|
|
assert_eq!(
|
|
singbox_package
|
|
.assets
|
|
.iter()
|
|
.map(|asset| (asset.component_id, asset.version.as_str()))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(CatalogComponentId::SingBox, "1.13.19"),
|
|
(CatalogComponentId::Winsw, "2.12.0"),
|
|
]
|
|
);
|
|
assert_eq!(transport.calls.load(Ordering::SeqCst), 0);
|
|
assert_eq!(bundle.calls.load(Ordering::SeqCst), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn local_resolver_is_stateless_and_rebuilds_the_same_plan() {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
let bundle = Arc::new(CountingBundleVerifier::default());
|
|
let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests(
|
|
Some(package_service(&paths)),
|
|
paths,
|
|
bundle.clone(),
|
|
);
|
|
|
|
let first = resolver
|
|
.resolve(PrivilegedAction::InstallProxifyre)
|
|
.expect("first resolve");
|
|
let second = resolver
|
|
.resolve(PrivilegedAction::InstallProxifyre)
|
|
.expect("second resolve");
|
|
|
|
assert_eq!(first, second);
|
|
assert_eq!(bundle.calls.load(Ordering::SeqCst), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn deleted_or_corrupt_ux_cache_converges_through_new_hint_then_asset_free_recovery() {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
let bundle = Arc::new(CountingBundleVerifier::default());
|
|
let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests_with_startup_session(
|
|
Some(package_service(&paths)),
|
|
paths.clone(),
|
|
bundle.clone(),
|
|
"11111111-1111-4111-8111-111111111111",
|
|
);
|
|
let missing_inventory = ComponentInventory {
|
|
component_id: proxywarden_lib::models::ComponentId::Proxyfier,
|
|
candidates: Vec::new(),
|
|
selected: None,
|
|
issues: Vec::new(),
|
|
};
|
|
|
|
let missing_cache = resolver
|
|
.resolve_cutover_action_with_inventory_for_tests(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
missing_inventory.clone(),
|
|
)
|
|
.expect("missing cache emits a side-effect-free new hint");
|
|
assert_eq!(
|
|
missing_cache
|
|
.cutover_context
|
|
.as_ref()
|
|
.expect("cutover context")
|
|
.mode,
|
|
PrivilegedCutoverMode::New
|
|
);
|
|
assert_eq!(
|
|
missing_cache
|
|
.package
|
|
.as_ref()
|
|
.expect("new hint exact bundle")
|
|
.assets
|
|
.len(),
|
|
3
|
|
);
|
|
assert!(!paths
|
|
.generated_dir
|
|
.join("proxifyre-app-config.json")
|
|
.exists());
|
|
|
|
fs::create_dir_all(&paths.state_dir).expect("state directory");
|
|
fs::write(&paths.component_cutover_observation_file, b"{").expect("corrupt UX observation");
|
|
let corrupt_cache = resolver
|
|
.resolve_cutover_action_with_inventory_for_tests(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
missing_inventory.clone(),
|
|
)
|
|
.expect("corrupt cache emits the same safe new hint");
|
|
assert_eq!(missing_cache, corrupt_cache);
|
|
|
|
let cutover_id = "22222222-2222-4222-8222-222222222222";
|
|
let observation = ComponentCutoverObservation {
|
|
schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION,
|
|
component: "proxifyre".to_string(),
|
|
cutover_id: cutover_id.to_string(),
|
|
state: CutoverDisplayState::AwaitingNextStart,
|
|
phase: CutoverPhase::LegacyQuarantined,
|
|
original_service_state: LegacyServiceState::Stopped,
|
|
legacy_version: "2.2.1".to_string(),
|
|
bundled_version: "2.4.0".to_string(),
|
|
operation_fingerprint: "a".repeat(64),
|
|
transaction_fingerprint: "b".repeat(64),
|
|
evidence_fingerprint: None,
|
|
next_start_verified: false,
|
|
route_smoke_confirmed: false,
|
|
legacy_path_label: "legacy-proxifyre".to_string(),
|
|
current_path_label: "current-proxifyre".to_string(),
|
|
can_recover: false,
|
|
can_cleanup: false,
|
|
disabled_code: None,
|
|
updated_at_epoch_seconds: 10,
|
|
};
|
|
fs::write(
|
|
&paths.component_cutover_observation_file,
|
|
serde_json::to_vec(&observation).expect("encode observation"),
|
|
)
|
|
.expect("restored redacted observation");
|
|
let recovery = resolver
|
|
.resolve_cutover_action_with_inventory_for_tests(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
missing_inventory,
|
|
)
|
|
.expect("recovery plan");
|
|
assert!(recovery.package.is_none());
|
|
assert_eq!(
|
|
recovery
|
|
.cutover_context
|
|
.as_ref()
|
|
.expect("recovery context")
|
|
.mode,
|
|
PrivilegedCutoverMode::Recovery
|
|
);
|
|
assert_eq!(bundle.calls.load(Ordering::SeqCst), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn non_absent_cutover_uses_canonical_probe_without_trusting_local_observation() {
|
|
for cache_case in ["missing", "corrupt", "stale-valid"] {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
fs::create_dir_all(&paths.state_dir).expect("state directory");
|
|
match cache_case {
|
|
"missing" => {}
|
|
"corrupt" => {
|
|
fs::write(&paths.component_cutover_observation_file, b"{")
|
|
.expect("corrupt observation");
|
|
}
|
|
"stale-valid" => {
|
|
let observation = ComponentCutoverObservation {
|
|
schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION,
|
|
component: "proxifyre".to_string(),
|
|
cutover_id: "22222222-2222-4222-8222-222222222222".to_string(),
|
|
state: CutoverDisplayState::Complete,
|
|
phase: CutoverPhase::ComponentComplete,
|
|
original_service_state: LegacyServiceState::Stopped,
|
|
legacy_version: "2.2.1".to_string(),
|
|
bundled_version: "2.4.0".to_string(),
|
|
operation_fingerprint: "a".repeat(64),
|
|
transaction_fingerprint: "b".repeat(64),
|
|
evidence_fingerprint: None,
|
|
next_start_verified: false,
|
|
route_smoke_confirmed: false,
|
|
legacy_path_label: "legacy-proxifyre".to_string(),
|
|
current_path_label: "current-proxifyre".to_string(),
|
|
can_recover: false,
|
|
can_cleanup: false,
|
|
disabled_code: None,
|
|
updated_at_epoch_seconds: 10,
|
|
};
|
|
fs::write(
|
|
&paths.component_cutover_observation_file,
|
|
serde_json::to_vec(&observation).expect("encode observation"),
|
|
)
|
|
.expect("stale terminal observation");
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
let bundle = Arc::new(CountingBundleVerifier::default());
|
|
let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests_with_cutover_app_root(
|
|
Some(package_service(&paths)),
|
|
paths,
|
|
bundle.clone(),
|
|
"11111111-1111-4111-8111-111111111111",
|
|
workspace.path().join("untrusted-test-app-root"),
|
|
);
|
|
let inventory = ComponentInventory {
|
|
component_id: proxywarden_lib::models::ComponentId::Proxyfier,
|
|
candidates: Vec::new(),
|
|
selected: None,
|
|
issues: Vec::new(),
|
|
};
|
|
let inventory_fingerprint = component_inventory_fingerprint_for_cutover(&inventory);
|
|
let plan = resolver
|
|
.resolve_cutover_action_with_inventory_for_tests(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
inventory,
|
|
)
|
|
.expect("canonical sealed recovery probe");
|
|
let context = plan.cutover_context.expect("probe context");
|
|
assert_eq!(context.mode, PrivilegedCutoverMode::Recovery);
|
|
assert_eq!(context.cutover_id, None);
|
|
assert_eq!(context.initiating_startup_session_id, None);
|
|
assert_eq!(context.user_evidence, None);
|
|
assert_eq!(
|
|
context.evidence_fingerprint,
|
|
cutover_recovery_probe_fingerprint(&inventory_fingerprint)
|
|
);
|
|
assert!(plan.package.is_none());
|
|
assert_eq!(bundle.calls.load(Ordering::SeqCst), 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn non_start_actions_do_not_require_generated_config_and_are_domain_separated() {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests(
|
|
Some(package_service(&paths)),
|
|
paths,
|
|
Arc::new(CountingBundleVerifier::default()),
|
|
);
|
|
|
|
let stop = resolver
|
|
.resolve(PrivilegedAction::StopProxifyre)
|
|
.expect("stop plan without generated config");
|
|
let uninstall = resolver
|
|
.resolve(PrivilegedAction::UninstallProxifyre)
|
|
.expect("uninstall plan without generated config");
|
|
assert_ne!(
|
|
stop.configuration_fingerprint,
|
|
uninstall.configuration_fingerprint
|
|
);
|
|
assert_eq!(
|
|
resolver.resolve(PrivilegedAction::StartProxifyre),
|
|
Err(PrivilegedRunnerFailure::PreconditionFailed)
|
|
);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn missing_or_corrupt_catalog_only_blocks_package_actions() {
|
|
let workspace = TestDirectory::new();
|
|
let missing_root = workspace.path().join("missing-components");
|
|
let corrupt_root = workspace.path().join("corrupt-components");
|
|
fs::create_dir(&corrupt_root).expect("create corrupt bundle root");
|
|
fs::write(corrupt_root.join("catalog.json"), b"{").expect("write corrupt catalog");
|
|
|
|
for (case, bundle_root) in [("missing", missing_root), ("corrupt", corrupt_root)] {
|
|
let paths = StoragePaths::new(workspace.path().join(format!("storage-{case}")));
|
|
assert!(
|
|
ComponentPackageService::open(&bundle_root, &paths).is_err(),
|
|
"fixture catalog must be unavailable"
|
|
);
|
|
safe_fs::write_restricted_atomic(
|
|
&paths.generated_dir.join("proxifyre-app-config.json"),
|
|
&proxifyre_config_bytes(),
|
|
)
|
|
.expect("restricted generated source");
|
|
let bundle = Arc::new(CountingBundleVerifier::default());
|
|
let local =
|
|
LocalPrivilegedPlanResolver::from_parts_for_tests(None, paths.clone(), bundle.clone());
|
|
let system = SystemPrivilegedRuntime::from_parts_for_tests(
|
|
None,
|
|
paths,
|
|
workspace
|
|
.path()
|
|
.join(format!("app-{case}"))
|
|
.join(".proxywarden-package-staging"),
|
|
bundle.clone(),
|
|
None,
|
|
Arc::new(RejectingSignatureVerifier),
|
|
);
|
|
|
|
for action in [
|
|
PrivilegedAction::StartProxifyre,
|
|
PrivilegedAction::StopProxifyre,
|
|
PrivilegedAction::UninstallSingBox,
|
|
PrivilegedAction::ConfigureProxifyreFirewall,
|
|
] {
|
|
assert!(local.resolve(action).is_ok(), "local {case}: {action:?}");
|
|
assert!(system.resolve(action).is_ok(), "system {case}: {action:?}");
|
|
}
|
|
for action in [
|
|
PrivilegedAction::InstallProxifyre,
|
|
PrivilegedAction::InstallSingBox,
|
|
PrivilegedAction::UpdateProxifyre,
|
|
PrivilegedAction::UpdateSingBox,
|
|
] {
|
|
assert_eq!(
|
|
local.resolve(action),
|
|
Err(PrivilegedRunnerFailure::PackageVerificationFailed),
|
|
"local {case}: {action:?}"
|
|
);
|
|
assert_eq!(
|
|
system.resolve(action),
|
|
Err(PrivilegedRunnerFailure::PackageVerificationFailed),
|
|
"system {case}: {action:?}"
|
|
);
|
|
}
|
|
assert_eq!(bundle.calls.load(Ordering::SeqCst), 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pending_action_is_consumed_on_plan_mismatch_without_transport_or_host_work() {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
let transport = Arc::new(CountingTransport::default());
|
|
let runtime = SystemPrivilegedRuntime::from_parts_for_tests(
|
|
Some(package_service(&paths)),
|
|
paths,
|
|
workspace.path().join("staging"),
|
|
Arc::new(CountingBundleVerifier::default()),
|
|
Some(transport.clone()),
|
|
Arc::new(RejectingSignatureVerifier),
|
|
);
|
|
let plan = runtime
|
|
.resolve(PrivilegedAction::StopProxifyre)
|
|
.expect("prepare stop");
|
|
let mut changed = plan.clone();
|
|
changed.inventory_fingerprint = "f".repeat(64);
|
|
|
|
assert_eq!(
|
|
runtime.run(PrivilegedAction::StopProxifyre, &changed),
|
|
Err(PrivilegedRunnerFailure::PreconditionFailed)
|
|
);
|
|
assert_eq!(
|
|
runtime.run(PrivilegedAction::StopProxifyre, &plan),
|
|
Err(PrivilegedRunnerFailure::PreconditionFailed)
|
|
);
|
|
assert_eq!(transport.calls.load(Ordering::SeqCst), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn stopped_cutover_allows_only_legacy_quarantined_start_stop_route_window() {
|
|
let config = "a".repeat(64);
|
|
for action in [
|
|
PrivilegedAction::StartProxifyre,
|
|
PrivilegedAction::StopProxifyre,
|
|
] {
|
|
assert_eq!(
|
|
stopped_cutover_route_lifecycle_exception_for_tests(
|
|
action,
|
|
CutoverPhase::LegacyQuarantined,
|
|
CutoverExecutionMode::AwaitingUser,
|
|
LegacyServiceState::Stopped,
|
|
&config,
|
|
),
|
|
Some(config.clone())
|
|
);
|
|
}
|
|
|
|
for (action, phase, mode, original_state) in [
|
|
(
|
|
PrivilegedAction::InstallProxifyre,
|
|
CutoverPhase::LegacyQuarantined,
|
|
CutoverExecutionMode::AwaitingUser,
|
|
LegacyServiceState::Stopped,
|
|
),
|
|
(
|
|
PrivilegedAction::StartProxifyre,
|
|
CutoverPhase::LegacyQuarantined,
|
|
CutoverExecutionMode::AwaitingUser,
|
|
LegacyServiceState::Running,
|
|
),
|
|
(
|
|
PrivilegedAction::StartProxifyre,
|
|
CutoverPhase::NextStartVerified,
|
|
CutoverExecutionMode::AwaitingUser,
|
|
LegacyServiceState::Stopped,
|
|
),
|
|
(
|
|
PrivilegedAction::StopProxifyre,
|
|
CutoverPhase::LegacyQuarantined,
|
|
CutoverExecutionMode::RollingBack,
|
|
LegacyServiceState::Stopped,
|
|
),
|
|
] {
|
|
assert_eq!(
|
|
stopped_cutover_route_lifecycle_exception_for_tests(
|
|
action,
|
|
phase,
|
|
mode,
|
|
original_state,
|
|
&config,
|
|
),
|
|
None,
|
|
"unexpected lifecycle exception for {action:?}/{phase:?}/{mode:?}/{original_state:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_plan_requires_user_evidence_from_the_same_normal_app_session() {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
fs::create_dir_all(&paths.state_dir).expect("state directory");
|
|
let current_inventory = managed_current_proxifyre_inventory();
|
|
let current_inventory_fingerprint =
|
|
component_inventory_fingerprint_for_cutover(¤t_inventory);
|
|
let cutover_id = "22222222-2222-4222-8222-222222222222";
|
|
let observation = ComponentCutoverObservation {
|
|
schema_version: CUTOVER_OBSERVATION_SCHEMA_VERSION,
|
|
component: "proxifyre".to_string(),
|
|
cutover_id: cutover_id.to_string(),
|
|
state: CutoverDisplayState::AwaitingNextStart,
|
|
phase: CutoverPhase::LegacyQuarantined,
|
|
original_service_state: LegacyServiceState::Stopped,
|
|
legacy_version: "2.2.1".to_string(),
|
|
bundled_version: "2.4.0".to_string(),
|
|
operation_fingerprint: "a".repeat(64),
|
|
transaction_fingerprint: "b".repeat(64),
|
|
evidence_fingerprint: None,
|
|
next_start_verified: false,
|
|
route_smoke_confirmed: false,
|
|
legacy_path_label: "legacy-proxifyre".to_string(),
|
|
current_path_label: "current-proxifyre".to_string(),
|
|
can_recover: false,
|
|
can_cleanup: false,
|
|
disabled_code: None,
|
|
updated_at_epoch_seconds: 10,
|
|
};
|
|
fs::write(
|
|
&paths.component_cutover_observation_file,
|
|
serde_json::to_vec(&observation).expect("encode observation"),
|
|
)
|
|
.expect("write observation");
|
|
let expected_session = "11111111-1111-4111-8111-111111111111";
|
|
let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests_with_startup_session(
|
|
None,
|
|
paths.clone(),
|
|
Arc::new(CountingBundleVerifier::default()),
|
|
expected_session,
|
|
);
|
|
let mut evidence = ComponentCutoverUserEvidence {
|
|
schema_version: CUTOVER_USER_EVIDENCE_SCHEMA_VERSION,
|
|
cutover_id: cutover_id.to_string(),
|
|
startup_session_id: "33333333-3333-4333-8333-333333333333".to_string(),
|
|
current_inventory_fingerprint,
|
|
route_smoke_confirmed: true,
|
|
observed_at_epoch_seconds: 11,
|
|
confirmed_at_epoch_seconds: Some(12),
|
|
};
|
|
fs::write(
|
|
&paths.component_cutover_user_evidence_file,
|
|
serde_json::to_vec(&evidence).expect("encode foreign-session evidence"),
|
|
)
|
|
.expect("write foreign-session evidence");
|
|
assert_eq!(
|
|
resolver.resolve_cutover_action_with_inventory_for_tests(
|
|
PrivilegedAction::CleanupProxifyreQuarantine,
|
|
current_inventory.clone(),
|
|
),
|
|
Err(PrivilegedRunnerFailure::CutoverStateConflict)
|
|
);
|
|
|
|
evidence.startup_session_id = expected_session.to_string();
|
|
fs::write(
|
|
&paths.component_cutover_user_evidence_file,
|
|
serde_json::to_vec(&evidence).expect("encode same-session evidence"),
|
|
)
|
|
.expect("write same-session evidence");
|
|
let cleanup = resolver
|
|
.resolve_cutover_action_with_inventory_for_tests(
|
|
PrivilegedAction::CleanupProxifyreQuarantine,
|
|
current_inventory,
|
|
)
|
|
.expect("same-session cleanup plan");
|
|
assert!(cleanup.package.is_none());
|
|
assert_eq!(
|
|
cleanup
|
|
.cutover_context
|
|
.as_ref()
|
|
.expect("cleanup context")
|
|
.mode,
|
|
PrivilegedCutoverMode::Cleanup
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_config_rejects_weak_acl_before_schema_or_runner() {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
fs::create_dir_all(&paths.generated_dir).expect("generated directory");
|
|
fs::write(
|
|
paths.generated_dir.join("proxifyre-app-config.json"),
|
|
proxifyre_config_bytes(),
|
|
)
|
|
.expect("ordinary file");
|
|
let resolver = LocalPrivilegedPlanResolver::from_parts_for_tests(
|
|
Some(package_service(&paths)),
|
|
paths,
|
|
Arc::new(CountingBundleVerifier::default()),
|
|
);
|
|
|
|
assert_eq!(
|
|
resolver.resolve(PrivilegedAction::StartProxifyre),
|
|
Err(PrivilegedRunnerFailure::PreconditionFailed)
|
|
);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn elevated_resolve_holds_exact_generated_source_and_mismatch_drops_it() {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
let generated = paths.generated_dir.join("proxifyre-app-config.json");
|
|
safe_fs::write_restricted_atomic(&generated, &proxifyre_config_bytes())
|
|
.expect("restricted generated source");
|
|
let runtime = SystemPrivilegedRuntime::from_parts_for_tests(
|
|
Some(package_service(&paths)),
|
|
paths,
|
|
workspace.path().join("staging"),
|
|
Arc::new(CountingBundleVerifier::default()),
|
|
Some(Arc::new(CountingTransport::default())),
|
|
Arc::new(RejectingSignatureVerifier),
|
|
);
|
|
let plan = runtime
|
|
.resolve(PrivilegedAction::StartProxifyre)
|
|
.expect("leased start plan");
|
|
assert!(
|
|
fs::write(&generated, proxifyre_config_bytes()).is_err(),
|
|
"deny-share-write lease must block source replacement"
|
|
);
|
|
let mut changed = plan.clone();
|
|
changed.configuration_fingerprint = "f".repeat(64);
|
|
assert_eq!(
|
|
runtime.run(PrivilegedAction::StartProxifyre, &changed),
|
|
Err(PrivilegedRunnerFailure::PreconditionFailed)
|
|
);
|
|
fs::write(&generated, proxifyre_config_bytes()).expect("lease released after mismatch");
|
|
}
|
|
|
|
#[test]
|
|
fn strict_generated_schema_rejects_unknown_and_dangerous_fields() {
|
|
let proxifyre = serde_json::from_slice::<Value>(&proxifyre_config_bytes()).expect("ProxiFyre");
|
|
assert!(validate_generated_config_bytes_for_tests(
|
|
ManagedComponent::Proxifyre,
|
|
&serde_json::to_vec(&proxifyre).expect("encode")
|
|
));
|
|
let mut unknown_root = proxifyre.clone();
|
|
unknown_root["command"] = json!("calc.exe");
|
|
assert!(!validate_generated_config_bytes_for_tests(
|
|
ManagedComponent::Proxifyre,
|
|
&serde_json::to_vec(&unknown_root).expect("encode")
|
|
));
|
|
let mut unknown_proxy = proxifyre;
|
|
unknown_proxy["proxies"][0]["password"] = json!("must-not-pass");
|
|
assert!(!validate_generated_config_bytes_for_tests(
|
|
ManagedComponent::Proxifyre,
|
|
&serde_json::to_vec(&unknown_proxy).expect("encode")
|
|
));
|
|
|
|
let singbox = singbox_config_value();
|
|
assert!(validate_generated_config_bytes_for_tests(
|
|
ManagedComponent::SingBox,
|
|
&serde_json::to_vec(&singbox).expect("encode")
|
|
));
|
|
let mut unknown_outbound = singbox.clone();
|
|
unknown_outbound["outbounds"][0]["executable"] = json!("foreign.exe");
|
|
assert!(!validate_generated_config_bytes_for_tests(
|
|
ManagedComponent::SingBox,
|
|
&serde_json::to_vec(&unknown_outbound).expect("encode")
|
|
));
|
|
let mut path_option = singbox;
|
|
path_option["outbounds"][0]["tls"]["certificate_path"] = json!("C:\\foreign.pem");
|
|
assert!(!validate_generated_config_bytes_for_tests(
|
|
ManagedComponent::SingBox,
|
|
&serde_json::to_vec(&path_option).expect("encode")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn production_subscription_adapter_outputs_are_authorized_for_all_supported_types() {
|
|
let bodies = [
|
|
json!({ "outbounds": [{
|
|
"type": "vless", "tag": "vless", "server": "vless.example.test",
|
|
"server_port": 443, "uuid": "11111111-1111-1111-1111-111111111111",
|
|
"flow": "", "packet_encoding": "xudp", "tls": { "enabled": true }
|
|
}]}),
|
|
json!({ "outbounds": [{
|
|
"type": "vmess", "tag": "vmess", "server": "vmess.example.test",
|
|
"server_port": 443, "uuid": "22222222-2222-2222-2222-222222222222",
|
|
"security": "auto", "tls": { "enabled": true },
|
|
"transport": { "type": "ws" }
|
|
}]}),
|
|
json!({ "outbounds": [{
|
|
"type": "trojan", "tag": "trojan", "server": "trojan.example.test",
|
|
"server_port": 443, "password": "trojan-secret", "tls": { "enabled": true }
|
|
}]}),
|
|
json!({ "outbounds": [{
|
|
"type": "shadowsocks", "tag": "shadowsocks", "server": "ss.example.test",
|
|
"server_port": 8388, "method": "aes-256-gcm", "password": "ss-secret"
|
|
}]}),
|
|
json!({ "outbounds": [{
|
|
"type": "hysteria2", "tag": "hysteria2", "server": "hy2.example.test",
|
|
"server_port": 443, "password": "hy2-secret",
|
|
"obfs": { "type": "salamander", "password": "obfs-secret" },
|
|
"tls": { "enabled": true }
|
|
}]}),
|
|
];
|
|
|
|
for body in bodies {
|
|
let generated = generated_from_subscription(&body.to_string());
|
|
assert!(
|
|
validate_generated_config_bytes_for_tests(
|
|
ManagedComponent::SingBox,
|
|
&serde_json::to_vec(&generated).expect("encode generated config")
|
|
),
|
|
"production adapter output was rejected: {generated}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn raw_vless_and_trojan_ws_tls_without_server_name_are_authorized() {
|
|
for kind in ["vless", "trojan"] {
|
|
let credential = if kind == "vless" {
|
|
json!({ "uuid": "33333333-3333-3333-3333-333333333333" })
|
|
} else {
|
|
json!({ "password": "trojan-secret" })
|
|
};
|
|
let mut outbound = json!({
|
|
"type": kind,
|
|
"tag": format!("{kind}-ws"),
|
|
"server": format!("{kind}.example.test"),
|
|
"server_port": 443,
|
|
"tls": { "enabled": true },
|
|
"transport": {
|
|
"type": "ws",
|
|
"path": "/socket",
|
|
"headers": { "Host": "front.example.test" }
|
|
}
|
|
});
|
|
outbound
|
|
.as_object_mut()
|
|
.expect("outbound object")
|
|
.extend(credential.as_object().expect("credential object").clone());
|
|
let generated =
|
|
generated_from_subscription(&json!({ "outbounds": [outbound] }).to_string());
|
|
|
|
assert!(validate_generated_config_bytes_for_tests(
|
|
ManagedComponent::SingBox,
|
|
&serde_json::to_vec(&generated).expect("encode generated config")
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn singbox_authorization_rejects_dangerous_passthrough_and_unsupported_shapes() {
|
|
let base = generated_from_subscription(
|
|
&json!({ "outbounds": [{
|
|
"type": "vless", "tag": "vless-ws", "server": "vless.example.test",
|
|
"server_port": 443, "uuid": "44444444-4444-4444-4444-444444444444",
|
|
"tls": { "enabled": true },
|
|
"transport": { "type": "ws" }
|
|
}]})
|
|
.to_string(),
|
|
);
|
|
let mut rejected = Vec::new();
|
|
for key in [
|
|
"command",
|
|
"service",
|
|
"executable",
|
|
"plugin",
|
|
"multiplex",
|
|
"detour",
|
|
"certificate_path",
|
|
"config_path",
|
|
] {
|
|
let mut value = base.clone();
|
|
value["outbounds"][0][key] = json!(r"C:\foreign\payload.exe");
|
|
rejected.push((key, value));
|
|
}
|
|
let mut unsupported_transport = base.clone();
|
|
unsupported_transport["outbounds"][0]["transport"]["type"] = json!("grpc");
|
|
rejected.push(("non-ws transport", unsupported_transport));
|
|
let mut foreign_header = base.clone();
|
|
foreign_header["outbounds"][0]["transport"]["headers"] = json!({ "Authorization": "secret" });
|
|
rejected.push(("foreign WS header", foreign_header));
|
|
let mut control_path = base.clone();
|
|
control_path["outbounds"][0]["transport"]["path"] = json!("/ws\ncontrol");
|
|
rejected.push(("control character in WS path", control_path));
|
|
let mut control_host = base.clone();
|
|
control_host["outbounds"][0]["transport"]["headers"] =
|
|
json!({ "Host": "front.example.test\r\nInjected: value" });
|
|
rejected.push(("control character in Host", control_host));
|
|
|
|
let mut shadowsocks_transport = generated_from_subscription(
|
|
&json!({ "outbounds": [{
|
|
"type": "shadowsocks", "tag": "ss", "server": "ss.example.test",
|
|
"server_port": 8388, "method": "aes-256-gcm", "password": "secret"
|
|
}]})
|
|
.to_string(),
|
|
);
|
|
shadowsocks_transport["outbounds"][0]["transport"] = json!({ "type": "ws" });
|
|
rejected.push(("transport on shadowsocks", shadowsocks_transport));
|
|
|
|
let mut wrong_obfs = generated_from_subscription(
|
|
&json!({ "outbounds": [{
|
|
"type": "hysteria2", "tag": "hy2", "server": "hy2.example.test",
|
|
"server_port": 443, "password": "secret",
|
|
"obfs": { "type": "other", "password": "obfs-secret" }
|
|
}]})
|
|
.to_string(),
|
|
);
|
|
wrong_obfs["outbounds"][0]["obfs"]["type"] = json!("other");
|
|
rejected.push(("non-salamander obfs", wrong_obfs));
|
|
|
|
for (label, value) in rejected {
|
|
assert!(
|
|
!validate_generated_config_bytes_for_tests(
|
|
ManagedComponent::SingBox,
|
|
&serde_json::to_vec(&value).expect("encode rejected config")
|
|
),
|
|
"dangerous shape was authorized: {label}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn cached_update_resolve_holds_exact_origin_and_locked_staging_until_one_shot_run() {
|
|
let workspace = TestDirectory::new();
|
|
let paths = StoragePaths::new(workspace.path().join("storage"));
|
|
let bytes = b"synthetic cached sing-box update";
|
|
let cached = seed_singbox_cache(&paths, bytes);
|
|
let transport = Arc::new(ExactMetadataTransport::new(metadata_for(&cached)));
|
|
let staging_parent = workspace
|
|
.path()
|
|
.join("app")
|
|
.join(".proxywarden-package-staging");
|
|
fs::create_dir_all(staging_parent.parent().expect("staging app root"))
|
|
.expect("create staging app root");
|
|
let packages = package_service(&paths);
|
|
assert_eq!(
|
|
packages
|
|
.plan_cached_update(CatalogComponentId::SingBox)
|
|
.expect("select trusted cached update"),
|
|
cached
|
|
);
|
|
let runtime = SystemPrivilegedRuntime::from_parts_for_tests(
|
|
Some(packages),
|
|
paths,
|
|
staging_parent.clone(),
|
|
Arc::new(CountingBundleVerifier::default()),
|
|
Some(transport.clone()),
|
|
Arc::new(RejectingSignatureVerifier),
|
|
);
|
|
|
|
let plan = runtime
|
|
.resolve(PrivilegedAction::UpdateSingBox)
|
|
.expect("prepare cached update");
|
|
assert_eq!(transport.calls.load(Ordering::SeqCst), 1);
|
|
let package = plan.package.as_ref().expect("cached package fingerprint");
|
|
let origin = package
|
|
.origin_proof
|
|
.as_ref()
|
|
.expect("official origin proof");
|
|
assert_eq!(package.version, cached.version);
|
|
assert_eq!(origin.repository, cached.independent_proof.repository);
|
|
assert_eq!(origin.release_id, cached.independent_proof.release_id);
|
|
assert_eq!(origin.asset_id, cached.independent_proof.asset_id);
|
|
assert_eq!(
|
|
origin.sha256_from_api,
|
|
cached.independent_proof.sha256_from_api
|
|
);
|
|
|
|
let staged_directory = only_child(&staging_parent);
|
|
let staged_asset = staged_directory.join(&cached.independent_proof.asset_name);
|
|
safe_fs::verify_path_protected_for_owner_admin_system(&staged_directory)
|
|
.expect("sealed staged directory");
|
|
safe_fs::verify_path_protected_for_owner_admin_system(&staged_asset)
|
|
.expect("sealed staged asset");
|
|
assert!(
|
|
fs::write(&staged_asset, b"replacement").is_err(),
|
|
"pending package lease must deny source replacement"
|
|
);
|
|
|
|
let mut mismatch = plan.clone();
|
|
mismatch.inventory_fingerprint = "f".repeat(64);
|
|
assert_eq!(
|
|
runtime.run(PrivilegedAction::UpdateSingBox, &mismatch),
|
|
Err(PrivilegedRunnerFailure::PreconditionFailed)
|
|
);
|
|
assert!(
|
|
fs::read_dir(&staging_parent)
|
|
.expect("read staging parent")
|
|
.next()
|
|
.is_none(),
|
|
"mismatch must drop the lease and remove its staging directory"
|
|
);
|
|
assert_eq!(
|
|
runtime.run(PrivilegedAction::UpdateSingBox, &plan),
|
|
Err(PrivilegedRunnerFailure::PreconditionFailed)
|
|
);
|
|
assert_eq!(transport.calls.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
fn package_service(paths: &StoragePaths) -> ComponentPackageService {
|
|
ComponentPackageService::open(bundled_root(), paths).expect("open bundled package service")
|
|
}
|
|
|
|
fn bundled_root() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("bundled/components")
|
|
}
|
|
|
|
fn proxifyre_config_bytes() -> Vec<u8> {
|
|
serde_json::to_vec(&json!({
|
|
"logLevel": "Info",
|
|
"bypassLan": true,
|
|
"proxies": [{
|
|
"appNames": ["browser.exe"],
|
|
"socks5ProxyEndpoint": "127.0.0.1:1080",
|
|
"supportedProtocols": ["TCP", "UDP"]
|
|
}]
|
|
}))
|
|
.expect("encode ProxiFyre config")
|
|
}
|
|
|
|
fn managed_current_proxifyre_inventory() -> ComponentInventory {
|
|
let root = PathBuf::from(r"C:\Program Files\ProxyWarden\components\ProxiFyre");
|
|
let executable = root.join("ProxiFyre.exe");
|
|
ComponentInventory {
|
|
component_id: proxywarden_lib::models::ComponentId::Proxyfier,
|
|
candidates: vec![ComponentCandidate {
|
|
component_id: proxywarden_lib::models::ComponentId::Proxyfier,
|
|
classification: ComponentClassification::ManagedCurrent,
|
|
role: CandidateRole::Current,
|
|
root,
|
|
executable_path: Some(executable.clone()),
|
|
binary_version: Some("2.4.0".to_string()),
|
|
service: Some(ServiceEvidence {
|
|
name: "ProxiFyreService".to_string(),
|
|
status: "Stopped".to_string(),
|
|
path_name: Some(executable.to_string_lossy().into_owned()),
|
|
executable_path: Some(executable),
|
|
path_matches_candidate: true,
|
|
binary_version: Some("2.4.0".to_string()),
|
|
}),
|
|
marker: MarkerEvidence::Valid,
|
|
issues: Vec::new(),
|
|
}],
|
|
selected: Some(0),
|
|
issues: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn singbox_config_value() -> Value {
|
|
json!({
|
|
"log": { "disabled": false, "level": "info", "timestamp": true },
|
|
"inbounds": [{
|
|
"type": "mixed",
|
|
"tag": "proxywarden-mixed-in",
|
|
"listen": "127.0.0.1",
|
|
"listen_port": 1080,
|
|
"users": [],
|
|
"set_system_proxy": false
|
|
}],
|
|
"outbounds": [
|
|
{
|
|
"type": "vless",
|
|
"tag": "vpn",
|
|
"server": "edge.example.test",
|
|
"server_port": 443,
|
|
"uuid": "11111111-1111-1111-1111-111111111111",
|
|
"flow": "xtls-rprx-vision",
|
|
"packet_encoding": "xudp",
|
|
"tls": {
|
|
"enabled": true,
|
|
"server_name": "edge.example.test",
|
|
"utls": { "enabled": true, "fingerprint": "chrome" },
|
|
"reality": {
|
|
"enabled": true,
|
|
"public_key": "fixture-public-key",
|
|
"short_id": "fixture-short-id"
|
|
}
|
|
}
|
|
},
|
|
{ "type": "direct", "tag": "direct" },
|
|
{ "type": "block", "tag": "block" }
|
|
],
|
|
"route": {
|
|
"rules": [{ "ip_is_private": true, "outbound": "direct" }],
|
|
"final": "vpn"
|
|
}
|
|
})
|
|
}
|
|
|
|
fn generated_from_subscription(body: &str) -> Value {
|
|
let parsed = parse_subscription_body(body).expect("parse subscription fixture");
|
|
let selected_server_id = parsed
|
|
.servers
|
|
.first()
|
|
.expect("supported subscription server")
|
|
.id
|
|
.clone();
|
|
let config = LocalSingBoxConfig {
|
|
selected_server_id: Some(selected_server_id),
|
|
..LocalSingBoxConfig::default()
|
|
};
|
|
let cache = SubscriptionCache {
|
|
config: parsed.config,
|
|
servers: parsed.servers,
|
|
user_info: Default::default(),
|
|
fetched_at: "test-fixture".to_string(),
|
|
};
|
|
let generated = SingBoxAdapter::default()
|
|
.generate_config(
|
|
SingBoxGenerationRequest::new(&config, &cache, None),
|
|
&NeverConfigChecker,
|
|
)
|
|
.expect("generate production sing-box config");
|
|
serde_json::from_str(&generated.contents).expect("decode generated sing-box config")
|
|
}
|
|
|
|
struct NeverConfigChecker;
|
|
|
|
impl SingBoxConfigChecker for NeverConfigChecker {
|
|
fn check_config(
|
|
&self,
|
|
_binary_path: &Path,
|
|
_config_json: &str,
|
|
) -> Result<SingBoxCheckResult, SingBoxConfigError> {
|
|
panic!("binary check is unreachable without a binary path")
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn seed_singbox_cache(paths: &StoragePaths, 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));
|
|
let proof = GithubReleaseDigestProof {
|
|
repository: "SagerNet/sing-box".to_string(),
|
|
release_id: 700,
|
|
asset_id: 701,
|
|
stable_tag: format!("v{version}"),
|
|
asset_name: asset_name.clone(),
|
|
size: bytes.len() as u64,
|
|
sha256_from_api: sha256.clone(),
|
|
verified_signatures: Vec::new(),
|
|
};
|
|
let plan = PrivilegedCachedUpdatePlan {
|
|
component_id: CatalogComponentId::SingBox,
|
|
version: version.to_string(),
|
|
independent_proof: proof.clone(),
|
|
};
|
|
let component_root = paths.packages_dir.join(plan.component_id.as_str());
|
|
let version_root = component_root.join(version);
|
|
fs::create_dir_all(&version_root).expect("create cached package directories");
|
|
let asset_path = version_root.join(&asset_name);
|
|
fs::write(&asset_path, bytes).expect("write cached package");
|
|
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: version.to_string(),
|
|
asset_name,
|
|
sha256,
|
|
size: bytes.len() as u64,
|
|
independent_proof: proof.clone(),
|
|
};
|
|
fs::write(
|
|
&manifest_path,
|
|
serde_json::to_vec_pretty(&manifest).expect("encode cache manifest"),
|
|
)
|
|
.expect("write cache manifest");
|
|
|
|
let trusted = 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 = ComponentUpdatesState {
|
|
schema_version: COMPONENT_UPDATES_STATE_SCHEMA_VERSION,
|
|
observations: vec![ComponentUpdateObservation {
|
|
component_id: plan.component_id,
|
|
checked_at_unix: 1,
|
|
latest_known_version: version.to_string(),
|
|
trusted_releases: vec![trusted],
|
|
}],
|
|
};
|
|
fs::create_dir_all(&paths.state_dir).expect("create update state directory");
|
|
fs::write(
|
|
&paths.component_updates_file,
|
|
serde_json::to_vec_pretty(&state).expect("encode update state"),
|
|
)
|
|
.expect("write update state");
|
|
|
|
for path in [
|
|
paths.packages_dir.as_path(),
|
|
component_root.as_path(),
|
|
version_root.as_path(),
|
|
manifest_path.as_path(),
|
|
asset_path.as_path(),
|
|
paths.state_dir.as_path(),
|
|
paths.component_updates_file.as_path(),
|
|
] {
|
|
safe_fs::protect_path_for_owner_admin_system(path).expect("protect trusted cache fixture");
|
|
}
|
|
plan
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
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("encode GitHub metadata")
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn only_child(parent: &Path) -> PathBuf {
|
|
let children = fs::read_dir(parent)
|
|
.expect("read parent")
|
|
.map(|entry| entry.expect("read child").path())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(children.len(), 1, "expected one staged lease");
|
|
children.into_iter().next().expect("staged child")
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct CountingBundleVerifier {
|
|
calls: AtomicUsize,
|
|
}
|
|
|
|
impl PrivilegedBundleVerifier for CountingBundleVerifier {
|
|
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)
|
|
{
|
|
Ok(())
|
|
} else {
|
|
Err(PrivilegedBundleVerificationError)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct CountingTransport {
|
|
calls: AtomicUsize,
|
|
}
|
|
|
|
impl UpdateTransport for CountingTransport {
|
|
fn get(
|
|
&self,
|
|
_request: &UpdateTransportRequest,
|
|
) -> Result<UpdateTransportResponse, UpdateTransportError> {
|
|
self.calls.fetch_add(1, Ordering::SeqCst);
|
|
Err(UpdateTransportError::RequestFailed)
|
|
}
|
|
}
|
|
|
|
struct RejectingSignatureVerifier;
|
|
|
|
impl PackageSignatureVerifier for RejectingSignatureVerifier {
|
|
fn verify(&self, _path: &Path) -> Result<SignaturePublisher, SignatureVerifierError> {
|
|
Err(SignatureVerifierError)
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
struct ExactMetadataTransport {
|
|
body: Vec<u8>,
|
|
calls: AtomicUsize,
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl ExactMetadataTransport {
|
|
fn new(body: Vec<u8>) -> Self {
|
|
Self {
|
|
body,
|
|
calls: AtomicUsize::new(0),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl UpdateTransport for ExactMetadataTransport {
|
|
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"
|
|
);
|
|
Ok(UpdateTransportResponse {
|
|
status: 200,
|
|
location: None,
|
|
content_length: Some(self.body.len() as u64),
|
|
body: Box::new(Cursor::new(self.body.clone())),
|
|
})
|
|
}
|
|
}
|
|
|
|
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("canonical target directory");
|
|
let path = target_root.join(format!("privileged-runtime-tests-{}", Uuid::new_v4()));
|
|
fs::create_dir(&path).expect("create isolated test directory");
|
|
Self { path, target_root }
|
|
}
|
|
|
|
fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
}
|
|
|
|
impl Drop for TestDirectory {
|
|
fn drop(&mut self) {
|
|
let exact_parent = self.path.parent() == Some(self.target_root.as_path());
|
|
let valid_name = self
|
|
.path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.and_then(|name| name.strip_prefix("privileged-runtime-tests-"))
|
|
.is_some_and(|id| Uuid::parse_str(id).is_ok());
|
|
if exact_parent
|
|
&& valid_name
|
|
&& self.path.is_absolute()
|
|
&& safe_fs::ensure_no_reparse_ancestors(&self.path).is_ok()
|
|
{
|
|
let _ = fs::remove_dir_all(&self.path);
|
|
}
|
|
}
|
|
}
|