use proxywarden_lib::component_catalog::{ComponentId, ComponentPackage, UpdateTrustPolicy}; use proxywarden_lib::component_packages::{ ComponentInstallSource, ComponentPackageService, ComponentPackagesError, ComponentUpdateState, InstalledComponentSnapshot, PackageCacheManifest, PackageSignatureVerifier, PackageSource, SignaturePublisher, SignatureVerifierError, UpdateCheckTrust, UpdateFreshness, UpdateRequestKind, UpdateTransport, UpdateTransportError, UpdateTransportRequest, UpdateTransportResponse, UPDATE_CHECK_STALE_AFTER_SECS, }; use proxywarden_lib::safe_fs::ensure_no_reparse_ancestors; use proxywarden_lib::storage::StoragePaths; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::collections::VecDeque; use std::fs; use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use uuid::Uuid; const CHECKED_AT: u64 = 1_800_000_000; const PUBLISHER: &str = "The Anti-Cloud Corporation"; #[test] fn trusted_check_persists_redacted_state_recovers_backup_and_reports_stale() { let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::Proxifyre); let asset_name = "ProxiFyre-v2.5.0-x64-signed.zip"; let bytes = b"release bytes"; let transport = MockTransport::new(vec![metadata_response( &component, "2.5.0", asset_name, bytes, DigestMode::Trusted, )]); let result = service .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &transport) .expect("trusted explicit check"); assert_eq!(result.trust, UpdateCheckTrust::Trusted); assert!(result.update_available); assert_eq!(transport.call_count(), 1); let persisted = fs::read_to_string(&workspace.state_path).expect("read update state"); assert!(!persisted.contains("https://")); assert!(!persisted.contains("browser_download_url")); assert!(!persisted.contains("?token=")); assert!(!persisted.contains("release bytes")); let fresh = service .update_status( ComponentId::Proxifyre, CHECKED_AT + 1, installed("2.4.0", ComponentInstallSource::Bundled), ) .expect("fresh status"); assert_eq!(fresh.freshness, UpdateFreshness::Fresh); assert_eq!(fresh.update_state, ComponentUpdateState::UpdateAvailable); assert_eq!(fresh.installed_version.as_deref(), Some("2.4.0")); assert_eq!(fresh.available_offline_version, "2.4.0"); assert_eq!(fresh.install_source, ComponentInstallSource::Bundled); assert_eq!(fresh.offline_package_source, PackageSource::Bundled); assert!(fresh.can_install_offline); assert!(fresh.can_download); let stale = service .update_status( ComponentId::Proxifyre, CHECKED_AT + UPDATE_CHECK_STALE_AFTER_SECS, installed("2.4.0", ComponentInstallSource::Bundled), ) .expect("stale status"); assert_eq!(stale.freshness, UpdateFreshness::Stale); assert_eq!(stale.update_state, ComponentUpdateState::CheckStale); let future_clock = service .update_status( ComponentId::Proxifyre, CHECKED_AT - 1, installed("2.4.0", ComponentInstallSource::Bundled), ) .expect("future timestamp status"); assert_eq!(future_clock.update_state, ComponentUpdateState::CheckStale); let second = MockTransport::new(vec![metadata_response( &component, "2.5.0", asset_name, bytes, DigestMode::Trusted, )]); service .check_for_update(ComponentId::Proxifyre, CHECKED_AT + 10, &second) .expect("second write creates backup"); fs::write(&workspace.state_path, b"{broken").expect("corrupt primary state"); let recovered = service .update_status( ComponentId::Proxifyre, CHECKED_AT + 20, installed("2.4.0", ComponentInstallSource::Bundled), ) .expect("recover valid backup"); assert_eq!(recovered.last_checked_at_unix, Some(CHECKED_AT)); serde_json::from_slice::(&fs::read(&workspace.state_path).expect("restored primary")) .expect("restored state must be JSON"); } #[test] fn missing_or_malformed_digest_is_observable_and_never_downloaded() { for (mode, expected) in [ ( DigestMode::Missing, UpdateCheckTrust::MissingIndependentDigest, ), ( DigestMode::Malformed, UpdateCheckTrust::MalformedIndependentDigest, ), ] { let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::SingBox); let transport = MockTransport::new(vec![metadata_response( &component, "1.14.0", "sing-box-1.14.0-windows-amd64.zip", b"asset", mode, )]); let result = service .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) .expect("untrusted observation is still visible"); assert_eq!(result.trust, expected); assert!(matches!( service.download_checked_update(ComponentId::SingBox, &transport, &NoopVerifier), Err(ComponentPackagesError::NoTrustedUpdate) )); assert_eq!(transport.call_count(), 1, "download transport must not run"); } } #[test] fn draft_prerelease_wrong_identity_and_ambiguous_metadata_are_rejected() { let seed_workspace = TestWorkspace::new(); let seed_service = seed_workspace.service(); let component = catalog_component(&seed_service, ComponentId::SingBox); let valid_response = metadata_response( &component, "1.14.0", "sing-box-1.14.0-windows-amd64.zip", b"asset", DigestMode::Trusted, ); let valid: Value = serde_json::from_slice(&valid_response.body).expect("parse seed metadata"); let mut cases = Vec::new(); let mut draft = valid.clone(); draft["draft"] = json!(true); cases.push(draft); let mut prerelease = valid.clone(); prerelease["prerelease"] = json!(true); cases.push(prerelease); let mut wrong_tag = valid.clone(); wrong_tag["tag_name"] = json!("v1.14.0-beta.1"); cases.push(wrong_tag); let mut wrong_asset = valid.clone(); wrong_asset["assets"][0]["name"] = json!("other.zip"); cases.push(wrong_asset); let mut mismatched_asset_version = valid.clone(); mismatched_asset_version["assets"][0]["name"] = json!("sing-box-9.9.9-windows-amd64.zip"); mismatched_asset_version["assets"][0]["browser_download_url"] = json!( "https://github.com/SagerNet/sing-box/releases/download/v1.14.0/sing-box-9.9.9-windows-amd64.zip" ); cases.push(mismatched_asset_version); let mut dotted_sing_version = valid.clone(); dotted_sing_version["assets"][0]["name"] = json!("sing-box-1.14.0.1-windows-amd64.zip"); dotted_sing_version["assets"][0]["browser_download_url"] = json!( "https://github.com/SagerNet/sing-box/releases/download/v1.14.0/sing-box-1.14.0.1-windows-amd64.zip" ); cases.push(dotted_sing_version); let mut wrong_repository = valid.clone(); wrong_repository["assets"][0]["browser_download_url"] = json!( "https://github.com/attacker/sing-box/releases/download/v1.14.0/sing-box-1.14.0-windows-amd64.zip" ); cases.push(wrong_repository); let mut ambiguous = valid; let mut duplicate = ambiguous["assets"][0].clone(); duplicate["id"] = json!(85); ambiguous["assets"] .as_array_mut() .expect("assets array") .push(duplicate); cases.push(ambiguous); for metadata in cases { let workspace = TestWorkspace::new(); let service = workspace.service(); let transport = MockTransport::new(vec![ResponseSpec::ok( serde_json::to_vec(&metadata).expect("serialize rejected metadata"), )]); assert!(service .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) .is_err()); assert_eq!(transport.call_count(), 1); assert!(!workspace.state_path.exists()); assert_bundled(&service, ComponentId::SingBox); } let proxifyre = catalog_component(&seed_service, ComponentId::Proxifyre); let dotted_proxifyre = metadata_response( &proxifyre, "2.5.0", "ProxiFyre-v2.5.0.1-x64-signed.zip", b"asset", DigestMode::Trusted, ); let workspace = TestWorkspace::new(); let service = workspace.service(); let transport = MockTransport::new(vec![dotted_proxifyre]); assert!(service .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &transport) .is_err()); assert_eq!(transport.call_count(), 1); assert!(!workspace.state_path.exists()); assert_bundled(&service, ComponentId::Proxifyre); } #[test] fn out_of_order_checks_never_regress_latest_observation() { let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::SingBox); let newer = MockTransport::new(vec![metadata_response( &component, "1.15.0", "sing-box-1.15.0-windows-amd64.zip", b"newer asset", DigestMode::Trusted, )]); service .check_for_update(ComponentId::SingBox, CHECKED_AT + 2, &newer) .expect("persist newer completion first"); let delayed_older = MockTransport::new(vec![metadata_response( &component, "1.14.0", "sing-box-1.14.0-windows-amd64.zip", b"older asset", DigestMode::Trusted, )]); service .check_for_update(ComponentId::SingBox, CHECKED_AT + 1, &delayed_older) .expect("merge delayed older proof without regressing observation"); let status = service .update_status( ComponentId::SingBox, CHECKED_AT + 3, installed("1.13.19", ComponentInstallSource::Bundled), ) .expect("read non-regressed status"); assert_eq!(status.latest_known_version.as_deref(), Some("1.15.0")); assert_eq!(status.last_checked_at_unix, Some(CHECKED_AT + 2)); let before_conflict = fs::read(&workspace.state_path).expect("read state before conflict"); let equal_timestamp_conflict = MockTransport::new(vec![metadata_response( &component, "1.16.0", "sing-box-1.16.0-windows-amd64.zip", b"conflicting asset", DigestMode::Trusted, )]); assert!(matches!( service.check_for_update( ComponentId::SingBox, CHECKED_AT + 2, &equal_timestamp_conflict ), Err(ComponentPackagesError::InvalidUpdateState) )); assert_eq!( fs::read(&workspace.state_path).expect("read state after conflict"), before_conflict ); } #[test] fn build_time_and_bundled_only_components_make_zero_transport_calls() { let workspace = TestWorkspace::new(); let service = workspace.service(); let transport = MockTransport::new(Vec::new()); for component_id in [ComponentId::VcRuntime, ComponentId::Winsw] { let result = service .check_for_update(component_id, CHECKED_AT, &transport) .expect("unsupported check result"); assert_eq!(result.trust, UpdateCheckTrust::Unsupported); assert!(!result.update_available); assert!(matches!( service.download_checked_update(component_id, &transport, &NoopVerifier), Err(ComponentPackagesError::NoTrustedUpdate) )); let status = service .update_status( component_id, CHECKED_AT, InstalledComponentSnapshot::not_installed(), ) .expect("unsupported status"); assert_eq!(status.update_state, ComponentUpdateState::Unsupported); assert_eq!(status.install_source, ComponentInstallSource::None); assert_eq!(status.available_offline_version, status.bundled_version); assert!(status.can_install_offline); } assert_eq!(transport.call_count(), 0); } #[test] fn status_uses_inventory_version_and_keeps_offline_package_separate() { let workspace = TestWorkspace::new(); let service = workspace.service(); let unchecked = service .update_status( ComponentId::Proxifyre, CHECKED_AT, installed("2.2.0", ComponentInstallSource::External), ) .expect("unchecked installed status"); assert_eq!(unchecked.installed_version.as_deref(), Some("2.2.0")); assert_eq!(unchecked.available_offline_version, "2.4.0"); assert_eq!(unchecked.install_source, ComponentInstallSource::External); assert_eq!(unchecked.offline_package_source, PackageSource::Bundled); assert_eq!(unchecked.update_state, ComponentUpdateState::UnknownOffline); let missing = service .update_status( ComponentId::Proxifyre, CHECKED_AT, InstalledComponentSnapshot::not_installed(), ) .expect("not-installed status"); assert_eq!(missing.installed_version, None); assert_eq!(missing.install_source, ComponentInstallSource::None); assert_eq!(missing.update_state, ComponentUpdateState::UnknownOffline); assert!(missing.can_install_offline); assert!(matches!( InstalledComponentSnapshot::new(Some(""), ComponentInstallSource::External), Err(ComponentPackagesError::InvalidInstalledSnapshot) )); assert!(matches!( InstalledComponentSnapshot::new(Some("service running"), ComponentInstallSource::External), Err(ComponentPackagesError::InvalidInstalledSnapshot) )); assert!(matches!( InstalledComponentSnapshot::new(None, ComponentInstallSource::External), Err(ComponentPackagesError::InvalidInstalledSnapshot) )); assert!(matches!( InstalledComponentSnapshot::new(Some("2.4.0"), ComponentInstallSource::None), Err(ComponentPackagesError::InvalidInstalledSnapshot) )); let component = catalog_component(&service, ComponentId::Proxifyre); let transport = MockTransport::new(vec![metadata_response( &component, "2.4.0", "ProxiFyre-v2.4.0-x64-signed.zip", b"same baseline release", DigestMode::Trusted, )]); service .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &transport) .expect("fresh baseline observation"); let older = service .update_status( ComponentId::Proxifyre, CHECKED_AT + 1, installed("2.2.0", ComponentInstallSource::External), ) .expect("older installed status"); assert_eq!(older.update_state, ComponentUpdateState::UpdateAvailable); let newer = service .update_status( ComponentId::Proxifyre, CHECKED_AT + 1, installed("9.0.0", ComponentInstallSource::External), ) .expect("newer external status"); assert_eq!(newer.update_state, ComponentUpdateState::Current); assert_eq!(newer.install_source, ComponentInstallSource::External); assert!(!newer.can_download); } #[test] fn sing_box_download_follows_only_the_exact_github_cdn_redirect_and_promotes() { let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::SingBox); let asset_name = "sing-box-1.14.0-windows-amd64.zip"; let bytes = b"verified sing-box archive".to_vec(); let cdn = "https://release-assets.githubusercontent.com/github-production-release-asset/1/object?token=secret"; let transport = MockTransport::new(vec![ metadata_response( &component, "1.14.0", asset_name, &bytes, DigestMode::Trusted, ), ResponseSpec::redirect(cdn), ResponseSpec::ok(bytes.clone()), ]); service .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) .expect("trusted sing-box check"); let selected = service .download_checked_update(ComponentId::SingBox, &transport, &NoopVerifier) .expect("download and promote sing-box"); assert_eq!(selected.source, PackageSource::Cache); assert_eq!(selected.version, "1.14.0"); assert_eq!(transport.call_count(), 3); assert_eq!(transport.requests()[2].url, cdn); let state = fs::read_to_string(&workspace.state_path).expect("read persisted state"); assert!(!state.contains("token=secret")); let status = service .update_status( ComponentId::SingBox, CHECKED_AT + 1, installed("1.14.0", ComponentInstallSource::Cache), ) .expect("downloaded current status"); assert_eq!(status.update_state, ComponentUpdateState::Current); assert!(!status.can_download); } #[test] fn prior_verified_cache_survives_untrusted_check_and_interrupted_newer_download() { let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::SingBox); let v114_name = "sing-box-1.14.0-windows-amd64.zip"; let v114 = b"trusted 1.14 package".to_vec(); let initial = MockTransport::new(vec![ metadata_response(&component, "1.14.0", v114_name, &v114, DigestMode::Trusted), ResponseSpec::ok(v114), ]); service .check_for_update(ComponentId::SingBox, CHECKED_AT, &initial) .expect("check 1.14"); service .download_checked_update(ComponentId::SingBox, &initial, &NoopVerifier) .expect("cache 1.14"); let changed_tag = b"different bytes for mutable tag".to_vec(); let mutable_tag = MockTransport::new(vec![metadata_response( &component, "1.14.0", v114_name, &changed_tag, DigestMode::Trusted, )]); assert!(matches!( service.check_for_update(ComponentId::SingBox, CHECKED_AT + 1, &mutable_tag), Err(ComponentPackagesError::InvalidUpdateState) )); assert_eq!( service .select_verified(ComponentId::SingBox) .expect("retain cache after mutable tag rejection") .version, "1.14.0" ); let v115_name = "sing-box-1.15.0-windows-amd64.zip"; let v115 = b"newer 1.15 package".to_vec(); let missing = MockTransport::new(vec![metadata_response( &component, "1.15.0", v115_name, &v115, DigestMode::Missing, )]); service .check_for_update(ComponentId::SingBox, CHECKED_AT + 2, &missing) .expect("observe 1.15 without proof"); let selected = service .select_verified(ComponentId::SingBox) .expect("retain prior cache after untrusted check"); assert_eq!( (selected.source, selected.version.as_str()), (PackageSource::Cache, "1.14.0") ); assert!(matches!( service.download_checked_update(ComponentId::SingBox, &missing, &NoopVerifier), Err(ComponentPackagesError::NoTrustedUpdate) )); let trusted_metadata = metadata_response(&component, "1.15.0", v115_name, &v115, DigestMode::Trusted); let interrupted = InterruptedTransport::new(trusted_metadata, v115); service .check_for_update(ComponentId::SingBox, CHECKED_AT + 3, &interrupted) .expect("trusted 1.15 check"); assert!(matches!( service.download_checked_update(ComponentId::SingBox, &interrupted, &NoopVerifier), Err(ComponentPackagesError::StagingIo(_)) )); let selected = service .select_verified(ComponentId::SingBox) .expect("retain prior cache after interrupted download"); assert_eq!( (selected.source, selected.version.as_str()), (PackageSource::Cache, "1.14.0") ); } #[test] fn second_redirect_and_hash_mismatch_never_promote() { let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::SingBox); let asset_name = "sing-box-1.14.0-windows-amd64.zip"; let expected = b"expected".to_vec(); let cdn = "https://release-assets.githubusercontent.com/github-production-release-asset/1/object?token=one"; let transport = MockTransport::new(vec![ metadata_response( &component, "1.14.0", asset_name, &expected, DigestMode::Trusted, ), ResponseSpec::redirect(cdn), ResponseSpec::redirect("https://release-assets.githubusercontent.com/other?token=two"), ]); service .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) .expect("trusted check"); assert!(matches!( service.download_checked_update(ComponentId::SingBox, &transport, &NoopVerifier), Err(ComponentPackagesError::InvalidRedirect) )); assert_bundled(&service, ComponentId::SingBox); let mismatch_workspace = TestWorkspace::new(); let mismatch_service = mismatch_workspace.service(); let component = catalog_component(&mismatch_service, ComponentId::SingBox); let mismatch = MockTransport::new(vec![ metadata_response( &component, "1.14.0", asset_name, &expected, DigestMode::Trusted, ), ResponseSpec::ok(b"tampered".to_vec()), ]); mismatch_service .check_for_update(ComponentId::SingBox, CHECKED_AT, &mismatch) .expect("trusted check"); assert!(matches!( mismatch_service.download_checked_update(ComponentId::SingBox, &mismatch, &NoopVerifier), Err(ComponentPackagesError::DigestMismatch) )); assert_bundled(&mismatch_service, ComponentId::SingBox); assert_no_staging_or_version(&mismatch_workspace, ComponentId::SingBox, "1.14.0"); } #[test] fn invalid_redirect_forms_are_rejected_before_any_asset_body_is_accepted() { for redirect in [ "http://release-assets.githubusercontent.com/object?token=x", "https://user@release-assets.githubusercontent.com/object?token=x", "https://release-assets.githubusercontent.com:444/object?token=x", "https://attacker.example/object?token=x", "https://release-assets.githubusercontent.com/object", ] { let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::SingBox); let bytes = b"expected".to_vec(); let transport = MockTransport::new(vec![ metadata_response( &component, "1.14.0", "sing-box-1.14.0-windows-amd64.zip", &bytes, DigestMode::Trusted, ), ResponseSpec::redirect(redirect), ]); service .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) .expect("trusted metadata"); assert!(matches!( service.download_checked_update(ComponentId::SingBox, &transport, &NoopVerifier), Err(ComponentPackagesError::InvalidRedirect) )); assert_eq!(transport.call_count(), 2); assert_no_staging_or_version(&workspace, ComponentId::SingBox, "1.14.0"); } } #[test] fn size_mismatch_and_interrupted_body_leave_no_active_cache() { let expected = b"complete package".to_vec(); let asset_name = "sing-box-1.14.0-windows-amd64.zip"; let size_workspace = TestWorkspace::new(); let size_service = size_workspace.service(); let component = catalog_component(&size_service, ComponentId::SingBox); let size_transport = MockTransport::new(vec![ metadata_response( &component, "1.14.0", asset_name, &expected, DigestMode::Trusted, ), ResponseSpec { status: 200, location: None, content_length: Some(expected.len() as u64 + 1), body: expected.clone(), }, ]); size_service .check_for_update(ComponentId::SingBox, CHECKED_AT, &size_transport) .expect("trusted metadata"); assert!(matches!( size_service.download_checked_update(ComponentId::SingBox, &size_transport, &NoopVerifier), Err(ComponentPackagesError::SizeMismatch) )); assert_no_staging_or_version(&size_workspace, ComponentId::SingBox, "1.14.0"); let interrupted_workspace = TestWorkspace::new(); let interrupted_service = interrupted_workspace.service(); let component = catalog_component(&interrupted_service, ComponentId::SingBox); let metadata = metadata_response( &component, "1.14.0", asset_name, &expected, DigestMode::Trusted, ); let interrupted = InterruptedTransport::new(metadata, expected); interrupted_service .check_for_update(ComponentId::SingBox, CHECKED_AT, &interrupted) .expect("trusted metadata"); assert!(matches!( interrupted_service.download_checked_update( ComponentId::SingBox, &interrupted, &NoopVerifier ), Err(ComponentPackagesError::StagingIo(_)) )); assert_no_staging_or_version(&interrupted_workspace, ComponentId::SingBox, "1.14.0"); } #[test] fn transport_timeout_never_writes_state_or_replaces_prior_cache() { let metadata_workspace = TestWorkspace::new(); let metadata_service = metadata_workspace.service(); let metadata_timeout = AlwaysFailTransport::default(); assert!(matches!( metadata_service.check_for_update(ComponentId::SingBox, CHECKED_AT, &metadata_timeout), Err(ComponentPackagesError::Transport) )); assert_eq!(metadata_timeout.call_count(), 1); assert!(!metadata_workspace.state_path.exists()); let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::SingBox); let old_name = "sing-box-1.14.0-windows-amd64.zip"; let old_bytes = b"trusted old cache".to_vec(); let initial = MockTransport::new(vec![ metadata_response( &component, "1.14.0", old_name, &old_bytes, DigestMode::Trusted, ), ResponseSpec::ok(old_bytes), ]); service .check_for_update(ComponentId::SingBox, CHECKED_AT, &initial) .expect("check old release"); service .download_checked_update(ComponentId::SingBox, &initial, &NoopVerifier) .expect("cache old release"); let new_bytes = b"trusted newer cache".to_vec(); let newer = MockTransport::new(vec![metadata_response( &component, "1.15.0", "sing-box-1.15.0-windows-amd64.zip", &new_bytes, DigestMode::Trusted, )]); service .check_for_update(ComponentId::SingBox, CHECKED_AT + 1, &newer) .expect("check newer release"); let prior = service .select_verified(ComponentId::SingBox) .expect("select prior cache before timeout"); let prior_asset = fs::read(&prior.asset_path).expect("read prior cached asset"); let prior_manifest = fs::read(prior.package_root.join("package.json")).expect("read prior cache manifest"); let prior_state = fs::read(&workspace.state_path).expect("read state before timeout"); let asset_timeout = AlwaysFailTransport::default(); assert!(matches!( service.download_checked_update(ComponentId::SingBox, &asset_timeout, &NoopVerifier), Err(ComponentPackagesError::Transport) )); assert_eq!(asset_timeout.call_count(), 1); let selected = service .select_verified(ComponentId::SingBox) .expect("prior cache remains selected"); assert_eq!(selected.version, "1.14.0"); assert_eq!( fs::read(&selected.asset_path).expect("read cache after timeout"), prior_asset ); assert_eq!( fs::read(selected.package_root.join("package.json")).expect("read manifest after timeout"), prior_manifest ); assert_eq!( fs::read(&workspace.state_path).expect("read state after timeout"), prior_state ); assert_no_staging_or_version(&workspace, ComponentId::SingBox, "1.15.0"); } #[test] fn wpf_requires_exact_common_name_and_organization() { let bytes = b"signed MSI bytes".to_vec(); let asset_name = "Windows.Packet.Filter.3.7.0.1.x64.msi"; let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::WindowsPacketFilter); let transport = MockTransport::new(vec![ metadata_response(&component, "3.7.0", asset_name, &bytes, DigestMode::Trusted), ResponseSpec::ok(bytes.clone()), ]); service .check_for_update(ComponentId::WindowsPacketFilter, CHECKED_AT, &transport) .expect("trusted WPF check"); let verifier = FixedVerifier::new(PUBLISHER, PUBLISHER); let selected = service .download_checked_update(ComponentId::WindowsPacketFilter, &transport, &verifier) .expect("exact publisher accepted"); assert_eq!(selected.source, PackageSource::Cache); assert_eq!(verifier.call_count(), 1); let bad_workspace = TestWorkspace::new(); let bad_service = bad_workspace.service(); let component = catalog_component(&bad_service, ComponentId::WindowsPacketFilter); let bad_transport = MockTransport::new(vec![ metadata_response(&component, "3.7.0", asset_name, &bytes, DigestMode::Trusted), ResponseSpec::ok(bytes), ]); bad_service .check_for_update(ComponentId::WindowsPacketFilter, CHECKED_AT, &bad_transport) .expect("trusted WPF check"); assert!(matches!( bad_service.download_checked_update( ComponentId::WindowsPacketFilter, &bad_transport, &FixedVerifier::new(PUBLISHER, "Attacker") ), Err(ComponentPackagesError::PublisherMismatch) )); assert_bundled(&bad_service, ComponentId::WindowsPacketFilter); } #[test] fn proxifyre_zip_requires_exact_unique_top_level_targets_and_records_both_signatures() { let bytes = proxifyre_zip(["ProxiFyre.exe", "socksify.dll"]); let workspace = TestWorkspace::new(); let service = workspace.service(); let component = catalog_component(&service, ComponentId::Proxifyre); let asset_name = "ProxiFyre-v2.5.0-x64-signed.zip"; let transport = MockTransport::new(vec![ metadata_response(&component, "2.5.0", asset_name, &bytes, DigestMode::Trusted), ResponseSpec::ok(bytes), ]); service .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &transport) .expect("trusted ProxiFyre check"); let selected = service .download_checked_update( ComponentId::Proxifyre, &transport, &FixedVerifier::new(PUBLISHER, PUBLISHER), ) .expect("verify both signed ZIP targets"); let manifest: PackageCacheManifest = serde_json::from_slice( &fs::read(selected.package_root.join("package.json")).expect("read manifest"), ) .expect("parse manifest"); let mut targets = manifest .independent_proof .verified_signatures .iter() .map(|evidence| evidence.target_name.as_str()) .collect::>(); targets.sort_unstable(); assert_eq!(targets, ["ProxiFyre.exe", "socksify.dll"]); for names in [ vec!["ProxiFyre.exe", "SOCKSIFY.DLL"], vec!["ProxiFyre.exe", "nested/socksify.dll"], vec!["ProxiFyre.exe", "proxifyre.exe", "socksify.dll"], vec!["ProxiFyre.exe", "socksify.dll", "socksify.dll/"], vec!["ProxiFyre.exe", "socksify.dll", "ProxiFyre.exe/evil"], ] { let bad_workspace = TestWorkspace::new(); let bad_service = bad_workspace.service(); let component = catalog_component(&bad_service, ComponentId::Proxifyre); let bad_zip = proxifyre_zip(names); let bad_transport = MockTransport::new(vec![ metadata_response( &component, "2.5.0", asset_name, &bad_zip, DigestMode::Trusted, ), ResponseSpec::ok(bad_zip), ]); bad_service .check_for_update(ComponentId::Proxifyre, CHECKED_AT, &bad_transport) .expect("trusted metadata"); assert!(matches!( bad_service.download_checked_update( ComponentId::Proxifyre, &bad_transport, &FixedVerifier::new(PUBLISHER, PUBLISHER) ), Err(ComponentPackagesError::InvalidArchive) )); } } fn bundled_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("bundled") .join("components") } fn catalog_component(service: &ComponentPackageService, id: ComponentId) -> ComponentPackage { service .catalog() .components .iter() .find(|component| component.id == id) .expect("component in production catalog") .clone() } fn repository(component: &ComponentPackage) -> &str { match &component.update_trust_policy { UpdateTrustPolicy::GithubReleaseDigest { repository, .. } => repository, _ => panic!("component does not use GitHub digest trust"), } } #[derive(Clone, Copy)] enum DigestMode { Trusted, Missing, Malformed, } fn metadata_response( component: &ComponentPackage, version: &str, asset_name: &str, asset_bytes: &[u8], digest_mode: DigestMode, ) -> ResponseSpec { let digest = format!("sha256:{:x}", Sha256::digest(asset_bytes)); let digest = match digest_mode { DigestMode::Trusted => Value::String(digest), DigestMode::Missing => Value::Null, DigestMode::Malformed => Value::String("sha256:not-a-digest".to_string()), }; let repository = repository(component); ResponseSpec::ok( serde_json::to_vec(&json!({ "id": 42, "tag_name": format!("v{version}"), "draft": false, "prerelease": false, "assets": [{ "id": 84, "name": asset_name, "size": asset_bytes.len(), "digest": digest, "browser_download_url": format!( "https://github.com/{repository}/releases/download/v{version}/{asset_name}" ) }] })) .expect("serialize GitHub fixture"), ) } fn assert_bundled(service: &ComponentPackageService, component_id: ComponentId) { assert_eq!( service .select_verified(component_id) .expect("bundled fallback") .source, PackageSource::Bundled ); } fn installed(version: &str, source: ComponentInstallSource) -> InstalledComponentSnapshot<'_> { InstalledComponentSnapshot::new(Some(version), source).expect("valid installed snapshot") } fn assert_no_staging_or_version( workspace: &TestWorkspace, component_id: ComponentId, version: &str, ) { let root = workspace.packages_root.join(component_id.as_str()); assert!(!root.join(version).exists(), "failed update became active"); if root.exists() { assert!( fs::read_dir(root) .expect("read component cache") .all(|entry| !entry .expect("cache entry") .file_name() .to_string_lossy() .starts_with(".staging-")), "failed update left staging behind" ); } } fn proxifyre_zip(names: I) -> Vec where I: IntoIterator, S: AsRef, { let cursor = Cursor::new(Vec::new()); let mut writer = zip::ZipWriter::new(cursor); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); for name in names { writer .start_file(name.as_ref(), options) .expect("start ZIP entry"); writer .write_all(b"signed PE fixture") .expect("write ZIP entry"); } writer.finish().expect("finish ZIP").into_inner() } struct ResponseSpec { status: u16, location: Option, content_length: Option, body: Vec, } impl ResponseSpec { fn ok(body: Vec) -> Self { Self { status: 200, location: None, content_length: Some(body.len() as u64), body, } } fn redirect(location: &str) -> Self { Self { status: 302, location: Some(location.to_string()), content_length: Some(0), body: Vec::new(), } } } struct MockTransport { responses: Mutex>, requests: Mutex>, } impl MockTransport { fn new(responses: Vec) -> Self { Self { responses: Mutex::new(responses.into()), requests: Mutex::new(Vec::new()), } } fn call_count(&self) -> usize { self.requests.lock().expect("request lock").len() } fn requests(&self) -> Vec { self.requests.lock().expect("request lock").clone() } } impl UpdateTransport for MockTransport { fn get( &self, request: &UpdateTransportRequest, ) -> Result { assert!(matches!( request.kind, UpdateRequestKind::GithubReleaseMetadata | UpdateRequestKind::PackageAsset )); self.requests .lock() .expect("request lock") .push(request.clone()); let response = self .responses .lock() .expect("response lock") .pop_front() .ok_or(UpdateTransportError::RequestFailed)?; Ok(UpdateTransportResponse { status: response.status, location: response.location, content_length: response.content_length, body: Box::new(Cursor::new(response.body)), }) } } #[derive(Default)] struct AlwaysFailTransport { calls: Mutex, } impl AlwaysFailTransport { fn call_count(&self) -> usize { *self.calls.lock().expect("request lock") } } impl UpdateTransport for AlwaysFailTransport { fn get( &self, _request: &UpdateTransportRequest, ) -> Result { *self.calls.lock().expect("request lock") += 1; Err(UpdateTransportError::RequestFailed) } } struct InterruptedTransport { metadata: Mutex>, asset: Vec, } impl InterruptedTransport { fn new(metadata: ResponseSpec, asset: Vec) -> Self { Self { metadata: Mutex::new(Some(metadata)), asset, } } } impl UpdateTransport for InterruptedTransport { fn get( &self, request: &UpdateTransportRequest, ) -> Result { if request.kind == UpdateRequestKind::GithubReleaseMetadata { let response = self .metadata .lock() .expect("metadata lock") .take() .ok_or(UpdateTransportError::RequestFailed)?; return Ok(UpdateTransportResponse { status: response.status, location: response.location, content_length: response.content_length, body: Box::new(Cursor::new(response.body)), }); } Ok(UpdateTransportResponse { status: 200, location: None, content_length: Some(self.asset.len() as u64), body: Box::new(InterruptedReader { bytes: self.asset[..self.asset.len() / 2].to_vec(), position: 0, failed: false, }), }) } } struct InterruptedReader { bytes: Vec, position: usize, failed: bool, } impl std::io::Read for InterruptedReader { fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { if self.position < self.bytes.len() { let count = buffer.len().min(self.bytes.len() - self.position); buffer[..count].copy_from_slice(&self.bytes[self.position..self.position + count]); self.position += count; return Ok(count); } if !self.failed { self.failed = true; return Err(std::io::Error::new( std::io::ErrorKind::ConnectionReset, "simulated interrupted download", )); } Ok(0) } } struct NoopVerifier; impl PackageSignatureVerifier for NoopVerifier { fn verify(&self, _path: &Path) -> Result { Err(SignatureVerifierError) } } struct FixedVerifier { common_name: String, organization: String, calls: Mutex, } impl FixedVerifier { fn new(common_name: &str, organization: &str) -> Self { Self { common_name: common_name.to_string(), organization: organization.to_string(), calls: Mutex::new(0), } } fn call_count(&self) -> usize { *self.calls.lock().expect("verifier call lock") } } impl PackageSignatureVerifier for FixedVerifier { fn verify(&self, _path: &Path) -> Result { *self.calls.lock().expect("verifier call lock") += 1; Ok(SignaturePublisher { common_name: self.common_name.clone(), organization: self.organization.clone(), }) } } struct TestWorkspace { root: PathBuf, packages_root: PathBuf, state_path: PathBuf, storage_paths: StoragePaths, } impl TestWorkspace { fn new() -> Self { let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); fs::create_dir_all(&target).expect("create Cargo target"); let root = target.join(format!("component-update-tests-{}", Uuid::new_v4())); fs::create_dir(&root).expect("create test workspace"); let storage_paths = StoragePaths::new(&root); Self { packages_root: storage_paths.packages_dir.clone(), state_path: storage_paths.component_updates_file.clone(), storage_paths, root, } } fn service(&self) -> ComponentPackageService { ComponentPackageService::open(bundled_root(), &self.storage_paths) .expect("open package service") } } impl Drop for TestWorkspace { fn drop(&mut self) { let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); if self.root.parent() == Some(target.as_path()) && self.root.file_name().is_some_and(|name| { name.to_string_lossy() .starts_with("component-update-tests-") }) && ensure_no_reparse_ancestors(&self.root).is_ok() { let _ = fs::remove_dir_all(&self.root); } } }