424 lines
14 KiB
Rust
424 lines
14 KiB
Rust
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(),
|
|
}
|
|
}
|