1252 lines
44 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|