@@ -0,0 +1,423 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user