Files
ProxyWarden/src-tauri/tests/component_package_update_tests.rs
dokril c6b66a4ed7
CI / Windows baseline (push) Canceled after 0s
Release v2.0.0
2026-09-11 18:29:33 +03:00

1252 lines
44 KiB
Rust

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::<Value>(&fs::read(&workspace.state_path).expect("restored primary"))
.expect("restored state must be JSON");
}
#[test]
fn large_multi_platform_release_selects_only_the_exact_windows_asset() {
let workspace = TestWorkspace::new();
let service = workspace.service();
let component = catalog_component(&service, ComponentId::SingBox);
let response = metadata_response(
&component,
"1.14.0",
"sing-box-1.14.0-windows-amd64.zip",
b"asset",
DigestMode::Trusted,
);
let mut metadata: Value = serde_json::from_slice(&response.body).unwrap();
// Real sing-box 1.14.0 publishes 167 assets. Response bytes are already bounded.
for index in 1..167 {
metadata["assets"].as_array_mut().unwrap().push(json!({
"id": 1000 + index,
"name": format!("other-platform-{index}.zip"),
"size": 10,
"browser_download_url": format!("https://github.com/SagerNet/sing-box/releases/download/v1.14.0/other-platform-{index}.zip")
}));
}
let transport = MockTransport::new(vec![ResponseSpec::ok(
serde_json::to_vec(&metadata).unwrap(),
)]);
let checked = service
.check_for_update(ComponentId::SingBox, CHECKED_AT, &transport)
.unwrap();
assert_eq!(checked.latest_known_version, "1.14.0");
assert_eq!(checked.trust, UpdateCheckTrust::Trusted);
}
#[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::<Vec<_>>();
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<I, S>(names: I) -> Vec<u8>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
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<String>,
content_length: Option<u64>,
body: Vec<u8>,
}
impl ResponseSpec {
fn ok(body: Vec<u8>) -> 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<VecDeque<ResponseSpec>>,
requests: Mutex<Vec<UpdateTransportRequest>>,
}
impl MockTransport {
fn new(responses: Vec<ResponseSpec>) -> 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<UpdateTransportRequest> {
self.requests.lock().expect("request lock").clone()
}
}
impl UpdateTransport for MockTransport {
fn get(
&self,
request: &UpdateTransportRequest,
) -> Result<UpdateTransportResponse, UpdateTransportError> {
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<usize>,
}
impl AlwaysFailTransport {
fn call_count(&self) -> usize {
*self.calls.lock().expect("request lock")
}
}
impl UpdateTransport for AlwaysFailTransport {
fn get(
&self,
_request: &UpdateTransportRequest,
) -> Result<UpdateTransportResponse, UpdateTransportError> {
*self.calls.lock().expect("request lock") += 1;
Err(UpdateTransportError::RequestFailed)
}
}
struct InterruptedTransport {
metadata: Mutex<Option<ResponseSpec>>,
asset: Vec<u8>,
}
impl InterruptedTransport {
fn new(metadata: ResponseSpec, asset: Vec<u8>) -> Self {
Self {
metadata: Mutex::new(Some(metadata)),
asset,
}
}
}
impl UpdateTransport for InterruptedTransport {
fn get(
&self,
request: &UpdateTransportRequest,
) -> Result<UpdateTransportResponse, UpdateTransportError> {
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<u8>,
position: usize,
failed: bool,
}
impl std::io::Read for InterruptedReader {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
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<SignaturePublisher, SignatureVerifierError> {
Err(SignatureVerifierError)
}
}
struct FixedVerifier {
common_name: String,
organization: String,
calls: Mutex<usize>,
}
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<SignaturePublisher, SignatureVerifierError> {
*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);
}
}
}