use proxywarden_lib::component_catalog::{ComponentId, ComponentPackage, UpdateTrustPolicy}; use proxywarden_lib::component_packages::{ ComponentPackageService, ComponentUpdateObservation, ComponentUpdatesState, GithubReleaseDigestProof, PackageCacheManifest, PackageSource, TrustedGithubReleaseObservation, COMPONENT_UPDATES_STATE_SCHEMA_VERSION, PACKAGE_CACHE_MANIFEST_FILENAME, PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, }; use proxywarden_lib::safe_fs::{ensure_no_reparse_ancestors, protect_path_for_owner_admin_system}; use proxywarden_lib::storage::StoragePaths; use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; #[cfg(windows)] use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; use uuid::Uuid; #[test] fn offline_selection_uses_the_real_bundled_package_without_a_cache() { let packages = TestDirectory::new(); let paths = StoragePaths::new(packages.path().join("missing-storage")); let service = ComponentPackageService::open(bundled_root(), &paths) .expect("open local component package service"); let selected = service .select_verified(ComponentId::Proxifyre) .expect("select bundled package offline"); assert_eq!(selected.source, PackageSource::Bundled); assert_eq!(selected.version, "2.4.0"); assert_eq!( selected.asset_path, bundled_root() .join("proxifyre") .join("ProxiFyre-v2.4.0-x64-signed.zip") ); } #[test] fn verified_newer_cache_wins_with_numeric_version_ordering() { let packages = TestDirectory::new(); let service = open_service(&packages); let component = sing_box_component(&service); let (expected, manifest) = write_verified_cache(&packages.packages_path(), &component, "1.100.0", |_| {}); write_trusted_state(&packages.state_path(), &manifest); let selected = service .select_verified(ComponentId::SingBox) .expect("select newest verified cache"); assert_eq!(selected.source, PackageSource::Cache); assert_eq!(selected.version, "1.100.0"); assert_eq!(selected.package_root, expected); } #[test] fn same_verified_cache_does_not_replace_the_bundle() { let packages = TestDirectory::new(); let service = open_service(&packages); let component = sing_box_component(&service); let (_, manifest) = write_verified_cache(&packages.packages_path(), &component, "1.13.19", |_| {}); write_trusted_state(&packages.state_path(), &manifest); assert_bundled(&service); } #[test] fn older_verified_cache_does_not_replace_the_bundle() { let packages = TestDirectory::new(); let service = open_service(&packages); let component = sing_box_component(&service); let (_, manifest) = write_verified_cache(&packages.packages_path(), &component, "1.13.18", |_| {}); write_trusted_state(&packages.state_path(), &manifest); assert_bundled(&service); } #[test] fn corrupt_cache_does_not_break_bundled_fallback() { let packages = TestDirectory::new(); let service = open_service(&packages); let component = sing_box_component(&service); let (version_root, manifest) = write_verified_cache(&packages.packages_path(), &component, "1.14.0", |_| {}); write_trusted_state(&packages.state_path(), &manifest); fs::write( version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME), b"{not-json", ) .expect("write corrupt manifest"); assert_bundled(&service); } #[test] fn cache_from_the_wrong_repository_is_rejected() { let packages = TestDirectory::new(); let service = open_service(&packages); let component = sing_box_component(&service); let (_, manifest) = write_verified_cache( &packages.packages_path(), &component, "1.14.0", |manifest| { manifest.independent_proof.repository = "attacker/sing-box".to_string(); }, ); write_trusted_state(&packages.state_path(), &manifest); assert_bundled(&service); } #[test] fn handwritten_far_future_cache_without_trusted_state_is_rejected() { let packages = TestDirectory::new(); let service = open_service(&packages); let component = sing_box_component(&service); write_verified_cache(&packages.packages_path(), &component, "999.0.0", |_| {}); assert_bundled(&service); } #[test] fn matching_manifest_and_state_with_inherited_acl_are_rejected() { let packages = TestDirectory::new(); let service = open_service(&packages); let component = sing_box_component(&service); let (_, manifest) = write_cache( &packages.packages_path(), &component, "1.14.0", |_| {}, false, ); write_state(&packages.state_path(), &manifest, false); assert_bundled(&service); } #[test] fn cache_with_an_extra_file_is_rejected() { let packages = TestDirectory::new(); let service = open_service(&packages); let component = sing_box_component(&service); let (version_root, manifest) = write_verified_cache(&packages.packages_path(), &component, "1.14.0", |_| {}); write_trusted_state(&packages.state_path(), &manifest); fs::write(version_root.join("unexpected.txt"), b"not part of package") .expect("write unexpected cache file"); assert_bundled(&service); } #[cfg(windows)] #[test] fn reparse_point_cache_root_is_rejected() { let workspace = TestDirectory::new(); let target = workspace.path().join("junction-target"); fs::create_dir_all(&target).expect("create junction target"); let storage_paths = workspace.storage_paths(); let service = ComponentPackageService::open(bundled_root(), &storage_paths) .expect("open service before creating junction"); let component = sing_box_component(&service); let (_, manifest) = write_verified_cache(&target, &component, "1.14.0", |_| {}); write_trusted_state(&storage_paths.component_updates_file, &manifest); let junction = storage_paths.packages_dir.clone(); let _junction_guard = create_junction(&junction, &target, workspace.path()); assert_bundled(&service); } fn bundled_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("bundled") .join("components") } fn open_service(packages: &TestDirectory) -> ComponentPackageService { ComponentPackageService::open(bundled_root(), &packages.storage_paths()) .expect("production bundle must open") } fn sing_box_component(service: &ComponentPackageService) -> ComponentPackage { service .catalog() .components .iter() .find(|component| component.id == ComponentId::SingBox) .expect("production sing-box component") .clone() } fn assert_bundled(service: &ComponentPackageService) { let selected = service .select_verified(ComponentId::SingBox) .expect("fall back to bundled package"); assert_eq!(selected.source, PackageSource::Bundled); assert_eq!(selected.version, "1.13.19"); } fn cache_version_root(packages_root: &Path, version: &str) -> PathBuf { packages_root .join(ComponentId::SingBox.as_str()) .join(version) } fn write_verified_cache( packages_root: &Path, component: &ComponentPackage, version: &str, mutate: impl FnOnce(&mut PackageCacheManifest), ) -> (PathBuf, PackageCacheManifest) { write_cache(packages_root, component, version, mutate, true) } fn write_cache( packages_root: &Path, component: &ComponentPackage, version: &str, mutate: impl FnOnce(&mut PackageCacheManifest), protect: bool, ) -> (PathBuf, PackageCacheManifest) { let version_root = cache_version_root(packages_root, version); fs::create_dir_all(&version_root).expect("create cache version directory"); let asset_name = format!("sing-box-{version}-windows-amd64.zip"); let asset_bytes = format!("verified sing-box package {version}").into_bytes(); let sha256 = format!("{:x}", Sha256::digest(&asset_bytes)); let repository = match &component.update_trust_policy { UpdateTrustPolicy::GithubReleaseDigest { repository, authenticode_publishers, .. } => { assert!( authenticode_publishers.is_none(), "sing-box cache must not claim Authenticode evidence" ); repository.clone() } _ => panic!("sing-box must use GitHub release digest trust"), }; let mut manifest = PackageCacheManifest { schema_version: PACKAGE_CACHE_MANIFEST_SCHEMA_VERSION, component_id: component.id, version: version.to_string(), asset_name: asset_name.clone(), sha256: sha256.clone(), size: asset_bytes.len() as u64, independent_proof: GithubReleaseDigestProof { repository, release_id: 1, asset_id: 1, stable_tag: format!("v{version}"), asset_name: asset_name.clone(), size: asset_bytes.len() as u64, sha256_from_api: sha256, verified_signatures: Vec::new(), }, }; mutate(&mut manifest); let asset_path = version_root.join(&asset_name); fs::write(&asset_path, asset_bytes).expect("write cached package asset"); let manifest_path = version_root.join(PACKAGE_CACHE_MANIFEST_FILENAME); fs::write( &manifest_path, serde_json::to_vec_pretty(&manifest).expect("serialize cache manifest"), ) .expect("write cache manifest"); if protect { let component_root = packages_root.join(component.id.as_str()); for path in [ packages_root, component_root.as_path(), version_root.as_path(), asset_path.as_path(), manifest_path.as_path(), ] { protect_path_for_owner_admin_system(path).expect("protect trusted cache path"); } } (version_root, manifest) } fn write_trusted_state(state_path: &Path, manifest: &PackageCacheManifest) { write_state(state_path, manifest, true); } fn write_state(state_path: &Path, manifest: &PackageCacheManifest, protect: bool) { let proof = &manifest.independent_proof; let checked_at_unix = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock must be after Unix epoch") .as_secs(); let state = ComponentUpdatesState { schema_version: COMPONENT_UPDATES_STATE_SCHEMA_VERSION, observations: vec![ComponentUpdateObservation { component_id: manifest.component_id, checked_at_unix, latest_known_version: manifest.version.clone(), trusted_releases: vec![TrustedGithubReleaseObservation { repository: proof.repository.clone(), release_id: proof.release_id, asset_id: proof.asset_id, stable_tag: proof.stable_tag.clone(), asset_name: proof.asset_name.clone(), size: proof.size, sha256_from_api: proof.sha256_from_api.clone(), }], }], }; let state_parent = state_path.parent().expect("state path has parent"); fs::create_dir_all(state_parent).expect("create state directory"); fs::write( state_path, serde_json::to_vec_pretty(&state).expect("serialize trusted update state"), ) .expect("write trusted update state"); if protect { protect_path_for_owner_admin_system(state_parent).expect("protect state parent"); protect_path_for_owner_admin_system(state_path).expect("protect trusted update state"); } } struct TestDirectory { path: PathBuf, target_root: PathBuf, } impl TestDirectory { fn new() -> Self { let target_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("target"); fs::create_dir_all(&target_root).expect("create Cargo target directory"); let target_root = fs::canonicalize(target_root).expect("canonicalize Cargo target directory"); let path = target_root.join(format!("component-package-tests-{}", Uuid::new_v4())); fs::create_dir(&path).expect("create isolated component package test directory"); Self { path, target_root } } fn path(&self) -> &Path { &self.path } fn storage_paths(&self) -> StoragePaths { StoragePaths::new(&self.path) } fn packages_path(&self) -> PathBuf { self.storage_paths().packages_dir } fn state_path(&self) -> PathBuf { self.storage_paths().component_updates_file } } impl Drop for TestDirectory { fn drop(&mut self) { let has_exact_parent = self.path.parent() == Some(self.target_root.as_path()); let has_test_name = self .path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| { name.strip_prefix("component-package-tests-") .is_some_and(|id| Uuid::parse_str(id).is_ok()) }); if has_exact_parent && has_test_name && self.path.is_absolute() && ensure_no_reparse_ancestors(&self.path).is_ok() { let _ = fs::remove_dir_all(&self.path); } } } #[cfg(windows)] struct JunctionGuard { path: PathBuf, expected_parent: PathBuf, } #[cfg(windows)] impl Drop for JunctionGuard { fn drop(&mut self) { if self.path.parent() == Some(self.expected_parent.as_path()) && self.path.file_name().is_some_and(|name| name == "packages") { let _ = fs::remove_dir(&self.path); } } } #[cfg(windows)] fn create_junction(path: &Path, target: &Path, expected_parent: &Path) -> JunctionGuard { assert_eq!(path.parent(), Some(expected_parent)); assert_eq!( path.file_name().and_then(|name| name.to_str()), Some("packages") ); let output = Command::new("cmd") .args(["/d", "/c", "mklink", "/J"]) .arg(path) .arg(target) .output() .expect("run mklink for reparse-point fixture"); assert!( output.status.success(), "mklink failed: {}", String::from_utf8_lossy(&output.stderr) ); JunctionGuard { path: path.to_path_buf(), expected_parent: expected_parent.to_path_buf(), } }