1826 lines
60 KiB
Rust
1826 lines
60 KiB
Rust
use proxywarden_lib::component_catalog::ComponentId as CatalogComponentId;
|
|
use proxywarden_lib::component_cutover::{
|
|
ComponentCutoverUserEvidence, CUTOVER_USER_EVIDENCE_SCHEMA_VERSION,
|
|
};
|
|
use proxywarden_lib::privileged_jobs::{
|
|
cutover_recovery_probe_fingerprint, execute_privileged_job, launch_privileged_job,
|
|
parse_early_helper_arguments, validate_install_receipt, CanonicalComponentRoot,
|
|
ElevatedJobLauncher, ElevatedLaunchOutcome, ElevationProbe, EpochClock, InstallReceipt,
|
|
InstallReceiptManager, InstalledAssetReceipt, InstalledFileReceipt, InstalledPackageSource,
|
|
LaunchedPrivilegedJob, ManagedComponent, PlannedAssetFingerprint, PlannedGithubOriginProof,
|
|
PlannedPackageFingerprint, PrivilegedAction, PrivilegedActionRunner, PrivilegedCutoverContext,
|
|
PrivilegedCutoverMode, PrivilegedJobId, PrivilegedJobLaunchState, PrivilegedJobStatus,
|
|
PrivilegedJobStore, PrivilegedJobsError, PrivilegedMutationResult, PrivilegedPlanResolver,
|
|
PrivilegedResultCode, PrivilegedRunnerFailure, ResolvedActionPlan,
|
|
INSTALL_RECEIPT_SCHEMA_VERSION, MAX_PRIVILEGED_RECORD_BYTES, PRIVILEGED_JOB_SCHEMA_VERSION,
|
|
};
|
|
use proxywarden_lib::process::{
|
|
known_service_path_matches_exact, service_path_matches_exact, KnownWindowsService,
|
|
};
|
|
use proxywarden_lib::safe_fs;
|
|
use proxywarden_lib::storage::StoragePaths;
|
|
use serde_json::Value;
|
|
use std::cell::{Cell, RefCell};
|
|
use std::ffi::OsString;
|
|
use std::fs;
|
|
#[cfg(windows)]
|
|
use std::fs::{File, OpenOptions};
|
|
use std::io;
|
|
#[cfg(windows)]
|
|
use std::io::{Read, Seek};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{mpsc, Arc, Mutex};
|
|
use std::thread;
|
|
use uuid::Uuid;
|
|
|
|
#[test]
|
|
fn early_mode_accepts_only_exact_canonical_uuid_v4_arguments() {
|
|
let id = Uuid::new_v4().hyphenated().to_string();
|
|
assert_eq!(
|
|
parse_early_helper_arguments(Vec::<OsString>::new()).expect("normal launch"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_early_helper_arguments([OsString::from("--other")]).expect("unreserved args"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
parse_early_helper_arguments([OsString::from("--elevated-helper"), OsString::from(&id),])
|
|
.expect("exact helper args")
|
|
.expect("helper id")
|
|
.as_str(),
|
|
id
|
|
);
|
|
assert!(matches!(
|
|
parse_early_helper_arguments([
|
|
OsString::from("--elevated-helper"),
|
|
OsString::from(id.to_uppercase()),
|
|
]),
|
|
Err(PrivilegedJobsError::InvalidJobId)
|
|
));
|
|
for invalid in ["not-a-uuid", "00000000-0000-0000-0000-000000000000"] {
|
|
assert!(matches!(
|
|
parse_early_helper_arguments([
|
|
OsString::from("--elevated-helper"),
|
|
OsString::from(invalid),
|
|
]),
|
|
Err(PrivilegedJobsError::InvalidJobId)
|
|
));
|
|
}
|
|
assert!(matches!(
|
|
parse_early_helper_arguments([
|
|
OsString::from("--elevated-helper"),
|
|
OsString::from(&id),
|
|
OsString::from("extra"),
|
|
]),
|
|
Err(PrivilegedJobsError::InvalidArguments)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn native_service_boundary_has_fixed_names_and_service_specific_paths() {
|
|
let expected = Path::new(r"C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe");
|
|
assert_eq!(KnownWindowsService::Proxifyre.name(), "ProxiFyreService");
|
|
assert_eq!(KnownWindowsService::SingBox.name(), "ProxyWardenSingBox");
|
|
assert!(service_path_matches_exact(
|
|
r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe""#,
|
|
expected
|
|
));
|
|
let bare = Path::new(r"C:\ProxyWarden\sing-box.exe");
|
|
assert!(service_path_matches_exact(
|
|
r"c:\proxywarden\sing-box.exe",
|
|
bare
|
|
));
|
|
assert!(!service_path_matches_exact(
|
|
r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe" --other"#,
|
|
expected
|
|
));
|
|
assert!(!service_path_matches_exact(
|
|
r"C:\Program Files\ProxyWarden\components\other.exe",
|
|
expected
|
|
));
|
|
assert!(known_service_path_matches_exact(
|
|
KnownWindowsService::Proxifyre,
|
|
r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe" -displayname "ProxiFyre Service" -servicename "ProxiFyreService""#,
|
|
expected,
|
|
));
|
|
assert!(!known_service_path_matches_exact(
|
|
KnownWindowsService::Proxifyre,
|
|
r#""C:\Program Files\ProxyWarden\components\ProxiFyre\ProxiFyre.exe""#,
|
|
expected,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn request_is_typed_and_contains_no_paths_programs_arguments_or_service_names() {
|
|
let fixture = Fixture::new("typed-request");
|
|
let launcher = RecordingLauncher::completed();
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::InstallProxifyre,
|
|
&StaticResolver::new(plan(PrivilegedAction::InstallProxifyre)),
|
|
&launcher,
|
|
);
|
|
let json = serde_json::to_string(&launched.request).expect("serialize request");
|
|
|
|
assert!(json.contains("proxifyre.install"));
|
|
assert!(!json.contains("C:\\"));
|
|
assert!(!json.contains("serviceName"));
|
|
assert!(!json.contains("program"));
|
|
assert!(!json.contains("arguments"));
|
|
let calls = launcher.calls.borrow();
|
|
assert_eq!(calls.len(), 1);
|
|
assert_eq!(calls[0].0, fixture.executable);
|
|
assert_eq!(calls[0].1, launched.request.job_id().as_str());
|
|
}
|
|
|
|
#[test]
|
|
fn schema_v2_actions_and_operation_fingerprints_are_typed_and_domain_separated() {
|
|
assert_eq!(PRIVILEGED_JOB_SCHEMA_VERSION, 2);
|
|
assert_eq!(
|
|
serde_json::to_string(&PrivilegedAction::CutoverProxifyre).expect("cutover action"),
|
|
"\"proxifyre.cutover\""
|
|
);
|
|
assert_eq!(
|
|
serde_json::to_string(&PrivilegedAction::CleanupProxifyreQuarantine)
|
|
.expect("cleanup action"),
|
|
"\"proxifyre.cleanup-quarantine\""
|
|
);
|
|
|
|
let stop = plan(PrivilegedAction::StopProxifyre);
|
|
let uninstall = plan(PrivilegedAction::UninstallProxifyre);
|
|
let cutover_new = cutover_plan(PrivilegedCutoverMode::New);
|
|
let cutover_recovery = cutover_plan(PrivilegedCutoverMode::Recovery);
|
|
assert_ne!(stop.operation_fingerprint, uninstall.operation_fingerprint);
|
|
assert_ne!(
|
|
cutover_new.operation_fingerprint,
|
|
cutover_recovery.operation_fingerprint
|
|
);
|
|
for fingerprint in [
|
|
&stop.operation_fingerprint,
|
|
&uninstall.operation_fingerprint,
|
|
&cutover_new.operation_fingerprint,
|
|
&cutover_recovery.operation_fingerprint,
|
|
] {
|
|
assert_eq!(fingerprint.len(), 64);
|
|
assert!(fingerprint.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cutover_asset_contract_is_conditional_and_exactly_ordered() {
|
|
let fixture = Fixture::new("cutover-assets");
|
|
let launcher = RecordingLauncher::completed();
|
|
for (action, value) in [
|
|
(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
cutover_plan(PrivilegedCutoverMode::New),
|
|
),
|
|
(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
cutover_plan(PrivilegedCutoverMode::Recovery),
|
|
),
|
|
(PrivilegedAction::CleanupProxifyreQuarantine, cleanup_plan()),
|
|
] {
|
|
fixture.launch(action, &StaticResolver::new(value), &launcher);
|
|
}
|
|
let new_plan = cutover_plan(PrivilegedCutoverMode::New);
|
|
assert_eq!(
|
|
new_plan
|
|
.package
|
|
.as_ref()
|
|
.expect("new cutover package")
|
|
.assets
|
|
.iter()
|
|
.map(|asset| asset.component_id)
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
CatalogComponentId::Proxifyre,
|
|
CatalogComponentId::WindowsPacketFilter,
|
|
CatalogComponentId::VcRuntime,
|
|
]
|
|
);
|
|
assert!(cutover_plan(PrivilegedCutoverMode::Recovery)
|
|
.package
|
|
.is_none());
|
|
assert!(cleanup_plan().package.is_none());
|
|
|
|
let mut recovery_with_package = cutover_plan(PrivilegedCutoverMode::Recovery);
|
|
recovery_with_package.package = new_plan.package.clone();
|
|
let mut cleanup_with_package = cleanup_plan();
|
|
cleanup_with_package.package = new_plan.package;
|
|
for (action, invalid) in [
|
|
(PrivilegedAction::CutoverProxifyre, recovery_with_package),
|
|
(
|
|
PrivilegedAction::CleanupProxifyreQuarantine,
|
|
cleanup_with_package,
|
|
),
|
|
] {
|
|
assert!(matches!(
|
|
launch_privileged_job(
|
|
&fixture.store,
|
|
action,
|
|
&fixture.executable,
|
|
&fixture.clock,
|
|
&StaticResolver::new(invalid),
|
|
&RecordingLauncher::completed(),
|
|
),
|
|
Err(PrivilegedJobsError::InvalidRecord)
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cutover_operation_fingerprint_binds_startup_session_and_typed_cleanup_evidence() {
|
|
let first_new = cutover_plan(PrivilegedCutoverMode::New);
|
|
let mut second_context = first_new
|
|
.cutover_context
|
|
.clone()
|
|
.expect("new cutover context");
|
|
second_context.initiating_startup_session_id =
|
|
Some("44444444-4444-4444-8444-444444444444".to_string());
|
|
let second_new = ResolvedActionPlan::new(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
first_new.package.clone(),
|
|
first_new.inventory_fingerprint.clone(),
|
|
first_new.configuration_fingerprint.clone(),
|
|
Some(second_context),
|
|
);
|
|
assert_ne!(
|
|
first_new.operation_fingerprint,
|
|
second_new.operation_fingerprint
|
|
);
|
|
|
|
let cleanup = cleanup_plan();
|
|
let mut changed_context = cleanup.cutover_context.clone().expect("cleanup context");
|
|
changed_context
|
|
.user_evidence
|
|
.as_mut()
|
|
.expect("typed user evidence")
|
|
.startup_session_id = "55555555-5555-4555-8555-555555555555".to_string();
|
|
let changed_cleanup = ResolvedActionPlan::new(
|
|
PrivilegedAction::CleanupProxifyreQuarantine,
|
|
None,
|
|
cleanup.inventory_fingerprint.clone(),
|
|
cleanup.configuration_fingerprint.clone(),
|
|
Some(changed_context),
|
|
);
|
|
assert_ne!(
|
|
cleanup.operation_fingerprint,
|
|
changed_cleanup.operation_fingerprint
|
|
);
|
|
|
|
let fixture = Fixture::new("cleanup-evidence");
|
|
fixture.launch(
|
|
PrivilegedAction::CleanupProxifyreQuarantine,
|
|
&StaticResolver::new(cleanup.clone()),
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let mut invalid_context = cleanup.cutover_context.expect("cleanup context");
|
|
invalid_context
|
|
.user_evidence
|
|
.as_mut()
|
|
.expect("typed user evidence")
|
|
.route_smoke_confirmed = false;
|
|
let invalid = ResolvedActionPlan::new(
|
|
PrivilegedAction::CleanupProxifyreQuarantine,
|
|
None,
|
|
"a".repeat(64),
|
|
"e".repeat(64),
|
|
Some(invalid_context),
|
|
);
|
|
assert!(matches!(
|
|
launch_privileged_job(
|
|
&fixture.store,
|
|
PrivilegedAction::CleanupProxifyreQuarantine,
|
|
&fixture.executable,
|
|
&fixture.clock,
|
|
&StaticResolver::new(invalid),
|
|
&RecordingLauncher::completed(),
|
|
),
|
|
Err(PrivilegedJobsError::InvalidRecord)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn sealed_recovery_probe_is_canonical_and_bound_to_exact_inventory() {
|
|
let inventory = "a".repeat(64);
|
|
let valid = recovery_probe_plan(&inventory);
|
|
let fixture = Fixture::new("valid-recovery-probe");
|
|
fixture.launch(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
&StaticResolver::new(valid.clone()),
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
|
|
let mut wrong_hash_context = valid.cutover_context.clone().expect("probe context");
|
|
wrong_hash_context.evidence_fingerprint = "b".repeat(64);
|
|
let wrong_hash = ResolvedActionPlan::new(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
None,
|
|
inventory.clone(),
|
|
"e".repeat(64),
|
|
Some(wrong_hash_context),
|
|
);
|
|
let wrong_inventory = ResolvedActionPlan::new(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
None,
|
|
"c".repeat(64),
|
|
"e".repeat(64),
|
|
valid.cutover_context,
|
|
);
|
|
|
|
for (case, invalid) in [
|
|
("wrong-hash", wrong_hash),
|
|
("wrong-inventory", wrong_inventory),
|
|
] {
|
|
let fixture = Fixture::new(case);
|
|
assert!(matches!(
|
|
launch_privileged_job(
|
|
&fixture.store,
|
|
PrivilegedAction::CutoverProxifyre,
|
|
&fixture.executable,
|
|
&fixture.clock,
|
|
&StaticResolver::new(invalid),
|
|
&RecordingLauncher::completed(),
|
|
),
|
|
Err(PrivilegedJobsError::InvalidRecord)
|
|
));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn strict_schema_and_size_limit_reject_before_runner() {
|
|
let fixture = Fixture::new("strict-schema");
|
|
let resolver = StaticResolver::new(plan(PrivilegedAction::StartProxifyre));
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::StartProxifyre,
|
|
&resolver,
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let request_path = fixture.request_path(launched.request.job_id());
|
|
let mut json: Value =
|
|
serde_json::from_slice(&fs::read(&request_path).expect("read request")).expect("json");
|
|
json.as_object_mut().expect("request object").insert(
|
|
"unexpectedPath".to_string(),
|
|
Value::String("C:\\evil".to_string()),
|
|
);
|
|
fs::write(&request_path, serde_json::to_vec(&json).expect("encode")).expect("mutate request");
|
|
let runner = CountingRunner::success();
|
|
assert!(matches!(
|
|
fixture.execute(&launched, &resolver, &runner),
|
|
Err(PrivilegedJobsError::InvalidRecord)
|
|
));
|
|
assert_eq!(runner.calls.get(), 0);
|
|
|
|
let second = fixture.launch(
|
|
PrivilegedAction::StartProxifyre,
|
|
&resolver,
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
fs::write(
|
|
fixture.request_path(second.request.job_id()),
|
|
vec![b' '; MAX_PRIVILEGED_RECORD_BYTES as usize + 1],
|
|
)
|
|
.expect("oversize request");
|
|
assert!(matches!(
|
|
fixture.execute(&second, &resolver, &runner),
|
|
Err(PrivilegedJobsError::RecordTooLarge)
|
|
));
|
|
assert_eq!(runner.calls.get(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn expired_and_future_dated_requests_are_rejected_without_claim() {
|
|
for (name, created_at, expected) in [
|
|
("expired", 699_u64, "expired"),
|
|
("future", 1_031_u64, "future"),
|
|
] {
|
|
let fixture = Fixture::new(name);
|
|
let resolver = StaticResolver::new(plan(PrivilegedAction::StopProxifyre));
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::StopProxifyre,
|
|
&resolver,
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
rewrite_request_field(
|
|
&fixture.request_path(launched.request.job_id()),
|
|
"createdAtEpochSeconds",
|
|
Value::from(created_at),
|
|
);
|
|
let error = fixture
|
|
.execute(&launched, &resolver, &CountingRunner::success())
|
|
.expect_err("invalid time");
|
|
assert!(
|
|
(expected == "expired" && matches!(error, PrivilegedJobsError::Expired))
|
|
|| (expected == "future" && matches!(error, PrivilegedJobsError::FutureDated))
|
|
);
|
|
assert!(!fixture.claim_path(launched.request.job_id()).exists());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn non_elevated_helper_does_not_create_system_state_or_call_runner() {
|
|
let fixture = Fixture::new("not-elevated");
|
|
let resolver = StaticResolver::new(plan(PrivilegedAction::StartSingBox));
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::StartSingBox,
|
|
&resolver,
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let runner = CountingRunner::success();
|
|
let error = execute_privileged_job(
|
|
&fixture.store,
|
|
launched.request.job_id(),
|
|
&fixture.clock,
|
|
&FixedElevation(false),
|
|
&resolver,
|
|
&runner,
|
|
)
|
|
.expect_err("non-elevated helper must fail");
|
|
|
|
assert!(matches!(error, PrivilegedJobsError::NotElevated));
|
|
assert_eq!(runner.calls.get(), 0);
|
|
assert!(!fixture.system_dir().exists());
|
|
}
|
|
|
|
#[test]
|
|
fn same_job_is_consumed_once_and_result_identity_is_validated() {
|
|
let fixture = Fixture::new("replay");
|
|
let resolver = StaticResolver::new(plan(PrivilegedAction::StartSingBox));
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::StartSingBox,
|
|
&resolver,
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let runner = CountingRunner::success();
|
|
let result = fixture
|
|
.execute(&launched, &resolver, &runner)
|
|
.expect("first execution");
|
|
assert_eq!(result.status, PrivilegedJobStatus::Succeeded);
|
|
assert_eq!(result.code, PrivilegedResultCode::Completed);
|
|
assert_eq!(runner.calls.get(), 1);
|
|
assert!(matches!(
|
|
fixture.execute(&launched, &resolver, &runner),
|
|
Err(PrivilegedJobsError::Replay)
|
|
));
|
|
assert_eq!(runner.calls.get(), 1);
|
|
assert_eq!(
|
|
fixture
|
|
.store
|
|
.read_result(&launched.request)
|
|
.expect("validated result"),
|
|
result
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cutover_success_and_failure_codes_are_stable_and_redacted() {
|
|
for (index, code) in [
|
|
PrivilegedResultCode::CutoverAwaitingNextStart,
|
|
PrivilegedResultCode::CutoverQuarantinePending,
|
|
PrivilegedResultCode::CutoverRolledBack,
|
|
PrivilegedResultCode::CutoverCleanupPending,
|
|
PrivilegedResultCode::CutoverComplete,
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let fixture = Fixture::new(&format!("cc-{index}"));
|
|
let action = PrivilegedAction::CutoverProxifyre;
|
|
let resolver = StaticResolver::new(cutover_plan(PrivilegedCutoverMode::Recovery));
|
|
let launched = fixture.launch(action, &resolver, &RecordingLauncher::completed());
|
|
let runner = CountingRunner {
|
|
calls: Cell::new(0),
|
|
result: Ok(PrivilegedMutationResult {
|
|
activation: None,
|
|
changed: true,
|
|
reboot_required: false,
|
|
result_code: Some(code),
|
|
}),
|
|
};
|
|
let result = fixture
|
|
.execute(&launched, &resolver, &runner)
|
|
.expect("stable cutover result");
|
|
assert_eq!(result.code, code);
|
|
assert!(!result.message.contains("C:\\"));
|
|
assert!(!result.message.contains(&"d".repeat(64)));
|
|
}
|
|
|
|
for (index, (failure, code)) in [
|
|
(
|
|
PrivilegedRunnerFailure::CutoverRecoveryRequired,
|
|
PrivilegedResultCode::CutoverRecoveryRequired,
|
|
),
|
|
(
|
|
PrivilegedRunnerFailure::CutoverStateConflict,
|
|
PrivilegedResultCode::CutoverStateConflict,
|
|
),
|
|
(
|
|
PrivilegedRunnerFailure::CutoverIdentityRejected,
|
|
PrivilegedResultCode::CutoverIdentityRejected,
|
|
),
|
|
]
|
|
.into_iter()
|
|
.enumerate()
|
|
{
|
|
let fixture = Fixture::new(&format!("cf-{index}"));
|
|
let action = PrivilegedAction::CutoverProxifyre;
|
|
let resolver = StaticResolver::new(cutover_plan(PrivilegedCutoverMode::Recovery));
|
|
let launched = fixture.launch(action, &resolver, &RecordingLauncher::completed());
|
|
let result = fixture
|
|
.execute(&launched, &resolver, &CountingRunner::failure(failure))
|
|
.expect("stable failure result");
|
|
assert_eq!(result.code, code);
|
|
assert_eq!(result.status, PrivilegedJobStatus::Failed);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn precreated_result_blocks_before_claim_and_runner() {
|
|
let fixture = Fixture::new("precreated-result");
|
|
let resolver = StaticResolver::new(plan(PrivilegedAction::StopSingBox));
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::StopSingBox,
|
|
&resolver,
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
fixture.prepare_portable_system_dir();
|
|
let result_path = fixture.result_path(launched.request.job_id());
|
|
fs::write(&result_path, b"{}").expect("precreate result");
|
|
safe_fs::protect_path_for_owner_admin_system(&result_path).expect("protect result");
|
|
let runner = CountingRunner::success();
|
|
|
|
assert!(matches!(
|
|
fixture.execute(&launched, &resolver, &runner),
|
|
Err(PrivilegedJobsError::ResultAlreadyExists)
|
|
));
|
|
assert_eq!(runner.calls.get(), 0);
|
|
assert!(!fixture.claim_path(launched.request.job_id()).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn uac_cancel_removes_only_held_request_and_never_creates_system_state() {
|
|
let fixture = Fixture::new("uac-cancel");
|
|
let resolver = StaticResolver::new(plan(PrivilegedAction::InstallSingBox));
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::InstallSingBox,
|
|
&resolver,
|
|
&RecordingLauncher::canceled(),
|
|
);
|
|
|
|
assert_eq!(launched.state, PrivilegedJobLaunchState::Canceled);
|
|
assert!(!fixture.request_path(launched.request.job_id()).exists());
|
|
assert!(!fixture.system_dir().exists());
|
|
}
|
|
|
|
#[test]
|
|
fn indeterminate_wait_keeps_request_for_the_already_started_helper() {
|
|
let fixture = Fixture::new("indeterminate-wait");
|
|
let resolver = StaticResolver::new(plan(PrivilegedAction::StartSingBox));
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::StartSingBox,
|
|
&resolver,
|
|
&RecordingLauncher::indeterminate(),
|
|
);
|
|
|
|
assert_eq!(launched.state, PrivilegedJobLaunchState::Indeterminate);
|
|
assert!(fixture.request_path(launched.request.job_id()).exists());
|
|
assert!(!fixture.system_dir().exists());
|
|
|
|
fixture
|
|
.execute(&launched, &resolver, &CountingRunner::success())
|
|
.expect("helper terminal result");
|
|
wait_until_request_is_removed(&fixture, launched.request.job_id());
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn indeterminate_keeper_blocks_request_write_delete_and_rename_until_terminal_result() {
|
|
let fixture = Fixture::new("indeterminate-keeper");
|
|
let action = PrivilegedAction::StartSingBox;
|
|
let resolver = StaticResolver::new(plan(action));
|
|
let launched = fixture.launch(action, &resolver, &RecordingLauncher::indeterminate());
|
|
let request = fixture.request_path(launched.request.job_id());
|
|
let renamed = request.with_extension("moved.json");
|
|
|
|
assert!(fs::write(&request, b"{}").is_err());
|
|
assert!(fs::remove_file(&request).is_err());
|
|
assert!(fs::rename(&request, &renamed).is_err());
|
|
assert!(request.exists());
|
|
assert!(!renamed.exists());
|
|
|
|
fixture
|
|
.execute(&launched, &resolver, &CountingRunner::success())
|
|
.expect("helper terminal result");
|
|
wait_until_request_is_removed(&fixture, launched.request.job_id());
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn parent_lease_blocks_tamper_while_helper_reads_and_executes() {
|
|
let fixture = Fixture::new("request-lease");
|
|
let action = PrivilegedAction::StartProxifyre;
|
|
let resolver = StaticResolver::new(plan(action));
|
|
let runner = CountingRunner::success();
|
|
let tamper_blocked = Cell::new(false);
|
|
let launcher = InlineHelperLauncher {
|
|
store: &fixture.store,
|
|
clock: &fixture.clock,
|
|
resolver: &resolver,
|
|
runner: &runner,
|
|
request_dir: fixture.paths.privileged_jobs_dir.clone(),
|
|
tamper_blocked: &tamper_blocked,
|
|
};
|
|
|
|
let launched = fixture.launch(action, &resolver, &launcher);
|
|
|
|
assert!(tamper_blocked.get());
|
|
let helper_result = fixture
|
|
.store
|
|
.read_result(&launched.request)
|
|
.expect("inline helper result");
|
|
assert_eq!(
|
|
helper_result.status,
|
|
PrivilegedJobStatus::Succeeded,
|
|
"{helper_result:?}"
|
|
);
|
|
assert_eq!(runner.calls.get(), 1);
|
|
assert!(matches!(
|
|
launched.state,
|
|
PrivilegedJobLaunchState::Completed { exit_code: 0 }
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn helper_rebuilds_plan_and_blocks_changed_inventory_before_runner() {
|
|
let fixture = Fixture::new("plan-change-helper");
|
|
let original = plan(PrivilegedAction::StartProxifyre);
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::StartProxifyre,
|
|
&StaticResolver::new(original.clone()),
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let mut changed = original;
|
|
changed.inventory_fingerprint = "b".repeat(64);
|
|
let runner = CountingRunner::success();
|
|
let result = fixture
|
|
.execute(&launched, &StaticResolver::new(changed), &runner)
|
|
.expect("structured rejected result");
|
|
|
|
assert_eq!(result.status, PrivilegedJobStatus::Failed);
|
|
assert_eq!(result.code, PrivilegedResultCode::PreconditionFailed);
|
|
assert_eq!(runner.calls.get(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn elevated_cutover_mode_mismatch_is_stable_and_never_calls_runner() {
|
|
let fixture = Fixture::new("cutover-mode-change");
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
&StaticResolver::new(cutover_plan(PrivilegedCutoverMode::New)),
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let runner = CountingRunner::success();
|
|
let result = fixture
|
|
.execute(
|
|
&launched,
|
|
&StaticResolver::new(cutover_plan(PrivilegedCutoverMode::Recovery)),
|
|
&runner,
|
|
)
|
|
.expect("structured cutover mode rejection");
|
|
|
|
assert_eq!(result.status, PrivilegedJobStatus::Failed);
|
|
assert_eq!(result.code, PrivilegedResultCode::CutoverStateConflict);
|
|
assert_eq!(runner.calls.get(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn client_rebuilds_plan_before_launch_and_removes_stale_request() {
|
|
let fixture = Fixture::new("plan-change-client");
|
|
let action = PrivilegedAction::InstallProxifyre;
|
|
let mut changed = plan(action);
|
|
changed.inventory_fingerprint = "c".repeat(64);
|
|
let resolver = SequenceResolver::new([plan(action), changed]);
|
|
let launcher = RecordingLauncher::completed();
|
|
|
|
assert!(matches!(
|
|
launch_privileged_job(
|
|
&fixture.store,
|
|
action,
|
|
&fixture.executable,
|
|
&fixture.clock,
|
|
&resolver,
|
|
&launcher,
|
|
),
|
|
Err(PrivilegedJobsError::PlanChanged)
|
|
));
|
|
assert!(launcher.calls.borrow().is_empty());
|
|
assert!(fs::read_dir(&fixture.paths.privileged_jobs_dir)
|
|
.expect("request dir")
|
|
.next()
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn client_removes_request_when_commit_plan_cannot_be_rebuilt() {
|
|
let fixture = Fixture::new("client-plan-error");
|
|
let action = PrivilegedAction::StartProxifyre;
|
|
let resolver = FailOnSecondResolve {
|
|
plan: plan(action),
|
|
calls: Cell::new(0),
|
|
};
|
|
let launcher = RecordingLauncher::completed();
|
|
|
|
assert!(launch_privileged_job(
|
|
&fixture.store,
|
|
action,
|
|
&fixture.executable,
|
|
&fixture.clock,
|
|
&resolver,
|
|
&launcher,
|
|
)
|
|
.is_err());
|
|
assert_eq!(launcher.calls.borrow().len(), 0);
|
|
let requests = fs::read_dir(&fixture.paths.privileged_jobs_dir)
|
|
.expect("request directory")
|
|
.filter_map(Result::ok)
|
|
.filter(|entry| {
|
|
entry
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.ends_with(".request.json")
|
|
})
|
|
.count();
|
|
assert_eq!(requests, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn store_owned_helper_binding_rejects_another_absolute_executable_before_launcher() {
|
|
let fixture = Fixture::new("helper-binding");
|
|
let action = PrivilegedAction::StartProxifyre;
|
|
let launcher = RecordingLauncher::completed();
|
|
let other_executable = fixture
|
|
.executable
|
|
.parent()
|
|
.expect("app root")
|
|
.join("CopiedProxyWarden.exe");
|
|
|
|
assert!(matches!(
|
|
launch_privileged_job(
|
|
&fixture.store,
|
|
action,
|
|
&other_executable,
|
|
&fixture.clock,
|
|
&StaticResolver::new(plan(action)),
|
|
&launcher,
|
|
),
|
|
Err(PrivilegedJobsError::InvalidArguments)
|
|
));
|
|
assert!(launcher.calls.borrow().is_empty());
|
|
assert!(!fixture.paths.privileged_jobs_dir.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn package_source_contract_is_bundled_for_install_and_cache_for_update() {
|
|
let fixture = Fixture::new("package-source");
|
|
let bad_install = plan_with_source(
|
|
PrivilegedAction::InstallProxifyre,
|
|
InstalledPackageSource::Cache,
|
|
);
|
|
let bad_update = plan_with_source(
|
|
PrivilegedAction::UpdateProxifyre,
|
|
InstalledPackageSource::Bundled,
|
|
);
|
|
for (action, bad_plan) in [
|
|
(PrivilegedAction::InstallProxifyre, bad_install),
|
|
(PrivilegedAction::UpdateProxifyre, bad_update),
|
|
] {
|
|
assert!(matches!(
|
|
launch_privileged_job(
|
|
&fixture.store,
|
|
action,
|
|
&fixture.executable,
|
|
&fixture.clock,
|
|
&StaticResolver::new(bad_plan),
|
|
&RecordingLauncher::completed(),
|
|
),
|
|
Err(PrivilegedJobsError::InvalidRecord)
|
|
));
|
|
}
|
|
|
|
let install = PrivilegedAction::InstallProxifyre;
|
|
let mut wrong_component = plan(install);
|
|
wrong_component
|
|
.package
|
|
.as_mut()
|
|
.expect("install package")
|
|
.assets[1]
|
|
.component_id = CatalogComponentId::Winsw;
|
|
let mut wrong_asset_version = plan(install);
|
|
wrong_asset_version
|
|
.package
|
|
.as_mut()
|
|
.expect("install package")
|
|
.assets[1]
|
|
.version = "latest".to_string();
|
|
let mut reordered = plan(install);
|
|
reordered
|
|
.package
|
|
.as_mut()
|
|
.expect("install package")
|
|
.assets
|
|
.swap(0, 1);
|
|
for invalid in [wrong_component, wrong_asset_version, reordered] {
|
|
assert!(matches!(
|
|
launch_privileged_job(
|
|
&fixture.store,
|
|
install,
|
|
&fixture.executable,
|
|
&fixture.clock,
|
|
&StaticResolver::new(invalid),
|
|
&RecordingLauncher::completed(),
|
|
),
|
|
Err(PrivilegedJobsError::InvalidRecord)
|
|
));
|
|
}
|
|
|
|
let update = PrivilegedAction::UpdateSingBox;
|
|
let mut invalid_origin_plans = Vec::new();
|
|
for mutate in [
|
|
|proof: &mut PlannedGithubOriginProof| proof.release_id = 0,
|
|
|proof: &mut PlannedGithubOriginProof| proof.asset_id = 0,
|
|
|proof: &mut PlannedGithubOriginProof| proof.stable_tag = "v9.9.9".to_string(),
|
|
|proof: &mut PlannedGithubOriginProof| proof.asset_name = "other.zip".to_string(),
|
|
|proof: &mut PlannedGithubOriginProof| proof.repository = "attacker/repo".to_string(),
|
|
|proof: &mut PlannedGithubOriginProof| proof.size += 1,
|
|
|proof: &mut PlannedGithubOriginProof| proof.sha256_from_api = "f".repeat(64),
|
|
] {
|
|
let mut invalid = plan(update);
|
|
mutate(
|
|
invalid
|
|
.package
|
|
.as_mut()
|
|
.and_then(|package| package.origin_proof.as_mut())
|
|
.expect("cache origin proof"),
|
|
);
|
|
invalid_origin_plans.push(invalid);
|
|
}
|
|
for invalid in invalid_origin_plans {
|
|
assert!(matches!(
|
|
launch_privileged_job(
|
|
&fixture.store,
|
|
update,
|
|
&fixture.executable,
|
|
&fixture.clock,
|
|
&StaticResolver::new(invalid),
|
|
&RecordingLauncher::completed(),
|
|
),
|
|
Err(PrivilegedJobsError::InvalidRecord)
|
|
));
|
|
}
|
|
|
|
let mut ambiguous_cache = plan(update);
|
|
ambiguous_cache
|
|
.package
|
|
.as_mut()
|
|
.expect("update package")
|
|
.assets
|
|
.push(PlannedAssetFingerprint {
|
|
component_id: CatalogComponentId::SingBox,
|
|
version: "1.2.3".to_string(),
|
|
name: "second-component.zip".to_string(),
|
|
sha256: "f".repeat(64),
|
|
size: 43,
|
|
});
|
|
let launcher = RecordingLauncher::completed();
|
|
assert!(matches!(
|
|
launch_privileged_job(
|
|
&fixture.store,
|
|
update,
|
|
&fixture.executable,
|
|
&fixture.clock,
|
|
&StaticResolver::new(ambiguous_cache),
|
|
&launcher,
|
|
),
|
|
Err(PrivilegedJobsError::InvalidRecord)
|
|
));
|
|
assert!(launcher.calls.borrow().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn helper_blocks_cached_origin_proof_id_swap_before_runner() {
|
|
let fixture = Fixture::new("origin-proof-swap");
|
|
let action = PrivilegedAction::UpdateProxifyre;
|
|
let initial = plan(action);
|
|
let launched = fixture.launch(
|
|
action,
|
|
&StaticResolver::new(initial.clone()),
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let mut changed = initial;
|
|
changed
|
|
.package
|
|
.as_mut()
|
|
.and_then(|package| package.origin_proof.as_mut())
|
|
.expect("cache origin proof")
|
|
.release_id += 1;
|
|
let runner = CountingRunner::success();
|
|
let result = fixture
|
|
.execute(&launched, &StaticResolver::new(changed), &runner)
|
|
.expect("structured precondition failure");
|
|
|
|
assert_eq!(result.code, PrivilegedResultCode::PreconditionFailed);
|
|
assert_eq!(runner.calls.get(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn helper_revalidates_ttl_after_resolver_and_returns_fixed_failure_without_runner() {
|
|
let fixture = Fixture::new("ttl-after-resolver");
|
|
let action = PrivilegedAction::StartProxifyre;
|
|
let original = plan(action);
|
|
let launched = fixture.launch(
|
|
action,
|
|
&StaticResolver::new(original.clone()),
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let execution_clock = MutableClock::new(1_000);
|
|
let resolver = ExpireDuringResolve {
|
|
plan: original,
|
|
clock: &execution_clock,
|
|
};
|
|
let runner = CountingRunner::success();
|
|
|
|
let result = execute_privileged_job(
|
|
&fixture.store,
|
|
launched.request.job_id(),
|
|
&execution_clock,
|
|
&FixedElevation(true),
|
|
&resolver,
|
|
&runner,
|
|
)
|
|
.expect("fixed terminal failure");
|
|
|
|
assert_eq!(result.status, PrivilegedJobStatus::Failed);
|
|
assert_eq!(result.code, PrivilegedResultCode::PreconditionFailed);
|
|
assert_eq!(runner.calls.get(), 0);
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn failed_final_result_replacement_preserves_running_bytes_exactly() {
|
|
let fixture = Fixture::new("atomic-final-result");
|
|
let action = PrivilegedAction::StopSingBox;
|
|
let resolver = StaticResolver::new(plan(action));
|
|
let launched = fixture.launch(action, &resolver, &RecordingLauncher::completed());
|
|
let runner = ResultPinningRunner::new(fixture.result_path(launched.request.job_id()));
|
|
|
|
assert!(matches!(
|
|
fixture.execute(&launched, &resolver, &runner),
|
|
Err(PrivilegedJobsError::Io(_))
|
|
));
|
|
let running_bytes = runner.release().expect("captured running bytes");
|
|
let persisted = fs::read(fixture.result_path(launched.request.job_id()))
|
|
.expect("running result remains readable");
|
|
|
|
assert_eq!(persisted, running_bytes);
|
|
let persisted_json: Value = serde_json::from_slice(&persisted).expect("result json");
|
|
assert_eq!(persisted_json["status"], "running");
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn result_watcher_open_read_lease_allows_terminal_atomic_replace() {
|
|
let fixture = Arc::new(Fixture::new("result-watcher-replace"));
|
|
let action = PrivilegedAction::StartProxifyre;
|
|
let resolver = StaticResolver::new(plan(action));
|
|
let launched = fixture.launch(action, &resolver, &RecordingLauncher::completed());
|
|
let (entered_tx, entered_rx) = mpsc::channel();
|
|
let (release_tx, release_rx) = mpsc::channel();
|
|
let runner = BlockingRunner {
|
|
entered: Mutex::new(Some(entered_tx)),
|
|
release: Mutex::new(release_rx),
|
|
};
|
|
let fixture_thread = Arc::clone(&fixture);
|
|
let resolver_thread = resolver.clone();
|
|
let job_id = launched.request.job_id().clone();
|
|
let handle = thread::spawn(move || {
|
|
execute_privileged_job(
|
|
&fixture_thread.store,
|
|
&job_id,
|
|
&fixture_thread.clock,
|
|
&FixedElevation(true),
|
|
&resolver_thread,
|
|
&runner,
|
|
)
|
|
});
|
|
entered_rx
|
|
.recv()
|
|
.expect("runner entered after Running write");
|
|
|
|
let (observed_running, mut read_lease) = fixture
|
|
.store
|
|
.read_result_with_open_lease_for_tests(&launched.request)
|
|
.expect("watcher reads Running and keeps the bounded read handle");
|
|
assert_eq!(observed_running.status, PrivilegedJobStatus::Running);
|
|
|
|
release_tx.send(()).expect("release runner");
|
|
let terminal = handle
|
|
.join()
|
|
.expect("helper thread")
|
|
.expect("ReplaceFileW succeeds while watcher read handle remains open");
|
|
assert_eq!(terminal.status, PrivilegedJobStatus::Succeeded);
|
|
assert_eq!(
|
|
fixture
|
|
.store
|
|
.read_result(&launched.request)
|
|
.expect("new terminal result is visible"),
|
|
terminal
|
|
);
|
|
|
|
let mut original_running_bytes = Vec::new();
|
|
read_lease
|
|
.seek(std::io::SeekFrom::Start(0))
|
|
.expect("rewind old result handle");
|
|
read_lease
|
|
.read_to_end(&mut original_running_bytes)
|
|
.expect("old open handle still references the replaced Running file");
|
|
let original_running: Value =
|
|
serde_json::from_slice(&original_running_bytes).expect("old Running json");
|
|
assert_eq!(original_running["status"], "running");
|
|
}
|
|
|
|
#[test]
|
|
fn results_are_fixed_and_do_not_echo_plan_fingerprints_or_paths() {
|
|
let fixture = Fixture::new("redaction");
|
|
let resolver = StaticResolver::new(plan(PrivilegedAction::UpdateProxifyre));
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::UpdateProxifyre,
|
|
&resolver,
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let result = fixture
|
|
.execute(
|
|
&launched,
|
|
&resolver,
|
|
&CountingRunner::failure(PrivilegedRunnerFailure::PackageVerificationFailed),
|
|
)
|
|
.expect("structured failure");
|
|
let json = serde_json::to_string(&result).expect("result json");
|
|
|
|
assert_eq!(result.code, PrivilegedResultCode::PackageVerificationFailed);
|
|
assert!(!json.contains(&"a".repeat(64)));
|
|
assert!(!json.contains("C:\\"));
|
|
assert!(!json.contains("https://"));
|
|
}
|
|
|
|
#[test]
|
|
fn system_directory_is_never_blessed_when_precreated_by_issuer() {
|
|
let fixture = Fixture::new("foreign-system");
|
|
let resolver = StaticResolver::new(plan(PrivilegedAction::StartSingBox));
|
|
let launched = fixture.launch(
|
|
PrivilegedAction::StartSingBox,
|
|
&resolver,
|
|
&RecordingLauncher::completed(),
|
|
);
|
|
let system = fixture.system_dir();
|
|
fs::create_dir_all(&system).expect("foreign system dir");
|
|
let sentinel = system.join("sentinel.txt");
|
|
fs::write(&sentinel, b"issuer-owned").expect("sentinel");
|
|
|
|
assert!(fixture
|
|
.execute(&launched, &resolver, &CountingRunner::success())
|
|
.is_err());
|
|
assert_eq!(
|
|
fs::read(&sentinel).expect("unchanged sentinel"),
|
|
b"issuer-owned"
|
|
);
|
|
assert!(!fixture.claim_path(launched.request.job_id()).exists());
|
|
}
|
|
|
|
#[test]
|
|
fn production_system_layout_is_outside_programdata_and_under_the_verified_app_root() {
|
|
let fixture = Fixture::new("production-system-layout");
|
|
let app_root = fixture.executable.parent().expect("app root");
|
|
let store = PrivilegedJobStore::from_storage_paths_with_verified_app_root_for_tests(
|
|
&fixture.paths,
|
|
app_root,
|
|
&fixture.executable,
|
|
);
|
|
let system = store.system_directory_for_tests();
|
|
|
|
assert_eq!(system, app_root.join(".proxywarden-privileged-jobs"));
|
|
assert!(!system.starts_with(&fixture.paths.root));
|
|
assert!(!system.starts_with(&fixture.paths.privileged_jobs_dir));
|
|
}
|
|
|
|
#[test]
|
|
fn global_lifecycle_lock_serializes_different_jobs() {
|
|
let fixture = Arc::new(Fixture::new("global-lock"));
|
|
let action = PrivilegedAction::StartProxifyre;
|
|
let resolver = StaticResolver::new(plan(action));
|
|
let first = fixture.launch(action, &resolver, &RecordingLauncher::completed());
|
|
let second = fixture.launch(action, &resolver, &RecordingLauncher::completed());
|
|
let (entered_tx, entered_rx) = mpsc::channel();
|
|
let (release_tx, release_rx) = mpsc::channel();
|
|
let blocking = BlockingRunner {
|
|
entered: Mutex::new(Some(entered_tx)),
|
|
release: Mutex::new(release_rx),
|
|
};
|
|
let fixture_thread = Arc::clone(&fixture);
|
|
let resolver_thread = resolver.clone();
|
|
let first_id = first.request.job_id().clone();
|
|
let handle = thread::spawn(move || {
|
|
execute_privileged_job(
|
|
&fixture_thread.store,
|
|
&first_id,
|
|
&fixture_thread.clock,
|
|
&FixedElevation(true),
|
|
&resolver_thread,
|
|
&blocking,
|
|
)
|
|
});
|
|
entered_rx.recv().expect("first runner entered");
|
|
|
|
assert!(matches!(
|
|
fixture.execute(&second, &resolver, &CountingRunner::success()),
|
|
Err(PrivilegedJobsError::LifecycleBusy)
|
|
));
|
|
release_tx.send(()).expect("release first runner");
|
|
assert!(handle.join().expect("helper thread").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn install_receipt_schema_rejects_wrong_root_traversal_hash_and_unknown_fields() {
|
|
let fixture = Fixture::new("receipt");
|
|
let root = CanonicalComponentRoot::from_current_exe_for_tests(
|
|
&fixture.executable,
|
|
ManagedComponent::Proxifyre,
|
|
)
|
|
.expect("canonical root");
|
|
let receipt = valid_receipt(&root);
|
|
validate_install_receipt(&root, &receipt).expect("valid receipt");
|
|
|
|
let mut wrong = receipt.clone();
|
|
wrong.install_root.push_str("-other");
|
|
assert!(matches!(
|
|
validate_install_receipt(&root, &wrong),
|
|
Err(PrivilegedJobsError::InvalidReceipt)
|
|
));
|
|
let mut traversal = receipt.clone();
|
|
traversal.installed_files[0].relative_path = "..\\evil.exe".to_string();
|
|
assert!(validate_install_receipt(&root, &traversal).is_err());
|
|
let mut uppercase_hash = receipt.clone();
|
|
uppercase_hash.asset.sha256 = "A".repeat(64);
|
|
assert!(validate_install_receipt(&root, &uppercase_hash).is_err());
|
|
for invalid_version in ["", "running", "1.02", "1.2.3.4.5"] {
|
|
let mut invalid = receipt.clone();
|
|
invalid.version = invalid_version.to_string();
|
|
assert!(
|
|
validate_install_receipt(&root, &invalid).is_err(),
|
|
"version {invalid_version:?}"
|
|
);
|
|
}
|
|
for invalid_path in [
|
|
"CON",
|
|
"con.txt",
|
|
"PRN.json",
|
|
"AUX",
|
|
"NUL.log",
|
|
"CONIN$",
|
|
"conout$.txt",
|
|
"CLOCK$.json",
|
|
"COM1.exe",
|
|
"COM¹.txt",
|
|
"LPT9.txt",
|
|
"folder.\\file.exe",
|
|
"folder \\file.exe",
|
|
"bad<name>.exe",
|
|
"bad|name.exe",
|
|
"bad?name.exe",
|
|
"bad*name.exe",
|
|
"bad\"name.exe",
|
|
] {
|
|
let mut invalid = receipt.clone();
|
|
invalid.installed_files[0].relative_path = invalid_path.to_string();
|
|
assert!(
|
|
validate_install_receipt(&root, &invalid).is_err(),
|
|
"path {invalid_path:?}"
|
|
);
|
|
}
|
|
|
|
let mut invalid_asset = receipt.clone();
|
|
invalid_asset.asset.name = "bundle?.zip".to_string();
|
|
assert!(validate_install_receipt(&root, &invalid_asset).is_err());
|
|
|
|
let mut unicode_case_duplicate = receipt.clone();
|
|
unicode_case_duplicate
|
|
.installed_files
|
|
.push(InstalledFileReceipt {
|
|
relative_path: "BIN\\ÄPP.EXE".to_string(),
|
|
sha256: "c".repeat(64),
|
|
size: 7,
|
|
});
|
|
unicode_case_duplicate.installed_files[0].relative_path = "bin\\äpp.exe".to_string();
|
|
assert!(validate_install_receipt(&root, &unicode_case_duplicate).is_err());
|
|
|
|
let mut json = serde_json::to_value(&receipt).expect("receipt json");
|
|
json.as_object_mut().expect("object").insert(
|
|
"arbitraryCommand".to_string(),
|
|
Value::String("cmd.exe".to_string()),
|
|
);
|
|
assert!(serde_json::from_value::<InstallReceipt>(json).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn copied_executable_in_user_writable_location_is_not_a_trusted_component_root() {
|
|
let fixture = Fixture::new("untrusted-app-root");
|
|
fs::create_dir_all(fixture.executable.parent().expect("exe parent")).expect("app dir");
|
|
fs::write(&fixture.executable, b"not-an-installed-app").expect("copied exe");
|
|
|
|
assert!(CanonicalComponentRoot::from_current_exe(
|
|
&fixture.executable,
|
|
ManagedComponent::Proxifyre,
|
|
)
|
|
.is_err());
|
|
}
|
|
|
|
struct Fixture {
|
|
root: PathBuf,
|
|
paths: StoragePaths,
|
|
store: PrivilegedJobStore,
|
|
executable: PathBuf,
|
|
clock: FixedClock,
|
|
}
|
|
|
|
impl Fixture {
|
|
fn new(name: &str) -> Self {
|
|
let root = std::env::temp_dir().join(format!(
|
|
"proxywarden-privileged-{name}-{}",
|
|
Uuid::new_v4().hyphenated()
|
|
));
|
|
fs::create_dir_all(&root).expect("create fixture root");
|
|
let paths = StoragePaths::new(root.join("ProgramData"));
|
|
let executable = root.join("Program Files").join("ProxyWarden.exe");
|
|
let store = PrivilegedJobStore::from_storage_paths_for_tests(&paths, &executable);
|
|
Self {
|
|
root,
|
|
paths,
|
|
store,
|
|
executable,
|
|
clock: FixedClock(1_000),
|
|
}
|
|
}
|
|
|
|
fn launch(
|
|
&self,
|
|
action: PrivilegedAction,
|
|
resolver: &dyn PrivilegedPlanResolver,
|
|
launcher: &dyn ElevatedJobLauncher,
|
|
) -> LaunchedPrivilegedJob {
|
|
launch_privileged_job(
|
|
&self.store,
|
|
action,
|
|
&self.executable,
|
|
&self.clock,
|
|
resolver,
|
|
launcher,
|
|
)
|
|
.expect("launch job")
|
|
}
|
|
|
|
fn execute(
|
|
&self,
|
|
launched: &LaunchedPrivilegedJob,
|
|
resolver: &dyn PrivilegedPlanResolver,
|
|
runner: &dyn PrivilegedActionRunner,
|
|
) -> Result<proxywarden_lib::privileged_jobs::PrivilegedJobResult, PrivilegedJobsError> {
|
|
execute_privileged_job(
|
|
&self.store,
|
|
launched.request.job_id(),
|
|
&self.clock,
|
|
&FixedElevation(true),
|
|
resolver,
|
|
runner,
|
|
)
|
|
}
|
|
|
|
fn request_path(&self, id: &PrivilegedJobId) -> PathBuf {
|
|
self.paths
|
|
.privileged_jobs_dir
|
|
.join(format!("{}.request.json", id.as_str()))
|
|
}
|
|
|
|
fn system_dir(&self) -> PathBuf {
|
|
self.paths.privileged_jobs_dir.join("system")
|
|
}
|
|
|
|
fn claim_path(&self, id: &PrivilegedJobId) -> PathBuf {
|
|
self.system_dir()
|
|
.join(format!("{}.claim.json", id.as_str()))
|
|
}
|
|
|
|
fn result_path(&self, id: &PrivilegedJobId) -> PathBuf {
|
|
self.system_dir()
|
|
.join(format!("{}.result.json", id.as_str()))
|
|
}
|
|
|
|
fn prepare_portable_system_dir(&self) {
|
|
let system = self.system_dir();
|
|
fs::create_dir_all(&system).expect("system dir");
|
|
safe_fs::protect_path_for_owner_admin_system(&system).expect("protect system dir");
|
|
}
|
|
}
|
|
|
|
impl Drop for Fixture {
|
|
fn drop(&mut self) {
|
|
let _ = fs::remove_dir_all(&self.root);
|
|
}
|
|
}
|
|
|
|
fn wait_until_request_is_removed(fixture: &Fixture, id: &PrivilegedJobId) {
|
|
let request = fixture.request_path(id);
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
|
while request.exists() && std::time::Instant::now() < deadline {
|
|
thread::sleep(std::time::Duration::from_millis(10));
|
|
}
|
|
assert!(!request.exists(), "keeper did not release terminal request");
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct FixedClock(u64);
|
|
|
|
impl EpochClock for FixedClock {
|
|
fn now_epoch_seconds(&self) -> u64 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
struct MutableClock(Cell<u64>);
|
|
|
|
impl MutableClock {
|
|
fn new(value: u64) -> Self {
|
|
Self(Cell::new(value))
|
|
}
|
|
}
|
|
|
|
impl EpochClock for MutableClock {
|
|
fn now_epoch_seconds(&self) -> u64 {
|
|
self.0.get()
|
|
}
|
|
}
|
|
|
|
struct FixedElevation(bool);
|
|
|
|
impl ElevationProbe for FixedElevation {
|
|
fn is_elevated(&self) -> bool {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct StaticResolver {
|
|
plan: ResolvedActionPlan,
|
|
}
|
|
|
|
impl StaticResolver {
|
|
fn new(plan: ResolvedActionPlan) -> Self {
|
|
Self { plan }
|
|
}
|
|
}
|
|
|
|
impl PrivilegedPlanResolver for StaticResolver {
|
|
fn resolve(
|
|
&self,
|
|
_action: PrivilegedAction,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
Ok(self.plan.clone())
|
|
}
|
|
}
|
|
|
|
struct SequenceResolver {
|
|
plans: RefCell<Vec<ResolvedActionPlan>>,
|
|
}
|
|
|
|
impl SequenceResolver {
|
|
fn new(plans: impl IntoIterator<Item = ResolvedActionPlan>) -> Self {
|
|
let mut plans = plans.into_iter().collect::<Vec<_>>();
|
|
plans.reverse();
|
|
Self {
|
|
plans: RefCell::new(plans),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PrivilegedPlanResolver for SequenceResolver {
|
|
fn resolve(
|
|
&self,
|
|
_action: PrivilegedAction,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
self.plans
|
|
.borrow_mut()
|
|
.pop()
|
|
.ok_or(PrivilegedRunnerFailure::PreconditionFailed)
|
|
}
|
|
}
|
|
|
|
struct FailOnSecondResolve {
|
|
plan: ResolvedActionPlan,
|
|
calls: Cell<usize>,
|
|
}
|
|
|
|
impl PrivilegedPlanResolver for FailOnSecondResolve {
|
|
fn resolve(
|
|
&self,
|
|
_action: PrivilegedAction,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
let call = self.calls.get();
|
|
self.calls.set(call + 1);
|
|
if call == 0 {
|
|
Ok(self.plan.clone())
|
|
} else {
|
|
Err(PrivilegedRunnerFailure::OperationFailed)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ExpireDuringResolve<'a> {
|
|
plan: ResolvedActionPlan,
|
|
clock: &'a MutableClock,
|
|
}
|
|
|
|
impl PrivilegedPlanResolver for ExpireDuringResolve<'_> {
|
|
fn resolve(
|
|
&self,
|
|
_action: PrivilegedAction,
|
|
) -> Result<ResolvedActionPlan, PrivilegedRunnerFailure> {
|
|
self.clock
|
|
.0
|
|
.set(1_000 + proxywarden_lib::privileged_jobs::PRIVILEGED_JOB_TTL_SECONDS + 1);
|
|
Ok(self.plan.clone())
|
|
}
|
|
}
|
|
|
|
struct CountingRunner {
|
|
calls: Cell<usize>,
|
|
result: Result<PrivilegedMutationResult, PrivilegedRunnerFailure>,
|
|
}
|
|
|
|
impl CountingRunner {
|
|
fn success() -> Self {
|
|
Self {
|
|
calls: Cell::new(0),
|
|
result: Ok(PrivilegedMutationResult {
|
|
activation: None,
|
|
changed: true,
|
|
reboot_required: false,
|
|
result_code: None,
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn failure(failure: PrivilegedRunnerFailure) -> Self {
|
|
Self {
|
|
calls: Cell::new(0),
|
|
result: Err(failure),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PrivilegedActionRunner for CountingRunner {
|
|
fn run(
|
|
&self,
|
|
_action: PrivilegedAction,
|
|
_plan: &ResolvedActionPlan,
|
|
) -> Result<PrivilegedMutationResult, PrivilegedRunnerFailure> {
|
|
self.calls.set(self.calls.get() + 1);
|
|
self.result.clone()
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
struct ResultPinningRunner {
|
|
result_path: PathBuf,
|
|
running_bytes: RefCell<Option<Vec<u8>>>,
|
|
pin: RefCell<Option<File>>,
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl ResultPinningRunner {
|
|
fn new(result_path: PathBuf) -> Self {
|
|
Self {
|
|
result_path,
|
|
running_bytes: RefCell::new(None),
|
|
pin: RefCell::new(None),
|
|
}
|
|
}
|
|
|
|
fn release(&self) -> Option<Vec<u8>> {
|
|
self.pin.borrow_mut().take();
|
|
self.running_bytes.borrow_mut().take()
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl PrivilegedActionRunner for ResultPinningRunner {
|
|
fn run(
|
|
&self,
|
|
_action: PrivilegedAction,
|
|
_plan: &ResolvedActionPlan,
|
|
) -> Result<PrivilegedMutationResult, PrivilegedRunnerFailure> {
|
|
use std::os::windows::fs::OpenOptionsExt;
|
|
|
|
let running =
|
|
fs::read(&self.result_path).map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
let mut options = OpenOptions::new();
|
|
options.read(true).share_mode(0);
|
|
let pin = options
|
|
.open(&self.result_path)
|
|
.map_err(|_| PrivilegedRunnerFailure::OperationFailed)?;
|
|
self.running_bytes.replace(Some(running));
|
|
self.pin.replace(Some(pin));
|
|
Ok(PrivilegedMutationResult {
|
|
activation: None,
|
|
changed: true,
|
|
reboot_required: false,
|
|
result_code: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
struct BlockingRunner {
|
|
entered: Mutex<Option<mpsc::Sender<()>>>,
|
|
release: Mutex<mpsc::Receiver<()>>,
|
|
}
|
|
|
|
impl PrivilegedActionRunner for BlockingRunner {
|
|
fn run(
|
|
&self,
|
|
_action: PrivilegedAction,
|
|
_plan: &ResolvedActionPlan,
|
|
) -> Result<PrivilegedMutationResult, PrivilegedRunnerFailure> {
|
|
if let Some(sender) = self.entered.lock().expect("entered lock").take() {
|
|
sender.send(()).expect("signal entered");
|
|
}
|
|
self.release
|
|
.lock()
|
|
.expect("release lock")
|
|
.recv()
|
|
.expect("release signal");
|
|
Ok(PrivilegedMutationResult {
|
|
activation: None,
|
|
changed: true,
|
|
reboot_required: false,
|
|
result_code: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
struct RecordingLauncher {
|
|
outcome: ElevatedLaunchOutcome,
|
|
calls: RefCell<Vec<(PathBuf, String)>>,
|
|
}
|
|
|
|
impl RecordingLauncher {
|
|
fn completed() -> Self {
|
|
Self {
|
|
outcome: ElevatedLaunchOutcome::Completed { exit_code: 0 },
|
|
calls: RefCell::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn canceled() -> Self {
|
|
Self {
|
|
outcome: ElevatedLaunchOutcome::Canceled,
|
|
calls: RefCell::new(Vec::new()),
|
|
}
|
|
}
|
|
|
|
fn indeterminate() -> Self {
|
|
Self {
|
|
outcome: ElevatedLaunchOutcome::Indeterminate,
|
|
calls: RefCell::new(Vec::new()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ElevatedJobLauncher for RecordingLauncher {
|
|
fn launch(
|
|
&self,
|
|
executable: &Path,
|
|
job_id: &PrivilegedJobId,
|
|
) -> io::Result<ElevatedLaunchOutcome> {
|
|
self.calls
|
|
.borrow_mut()
|
|
.push((executable.to_path_buf(), job_id.as_str().to_string()));
|
|
Ok(self.outcome)
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
struct InlineHelperLauncher<'a> {
|
|
store: &'a PrivilegedJobStore,
|
|
clock: &'a dyn EpochClock,
|
|
resolver: &'a dyn PrivilegedPlanResolver,
|
|
runner: &'a dyn PrivilegedActionRunner,
|
|
request_dir: PathBuf,
|
|
tamper_blocked: &'a Cell<bool>,
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl ElevatedJobLauncher for InlineHelperLauncher<'_> {
|
|
fn launch(
|
|
&self,
|
|
_executable: &Path,
|
|
job_id: &PrivilegedJobId,
|
|
) -> io::Result<ElevatedLaunchOutcome> {
|
|
let request = self
|
|
.request_dir
|
|
.join(format!("{}.request.json", job_id.as_str()));
|
|
self.tamper_blocked.set(fs::write(&request, b"{}").is_err());
|
|
execute_privileged_job(
|
|
self.store,
|
|
job_id,
|
|
self.clock,
|
|
&FixedElevation(true),
|
|
self.resolver,
|
|
self.runner,
|
|
)
|
|
.map_err(io::Error::other)?;
|
|
Ok(ElevatedLaunchOutcome::Completed { exit_code: 0 })
|
|
}
|
|
}
|
|
|
|
fn plan(action: PrivilegedAction) -> ResolvedActionPlan {
|
|
let package = action.requires_test_package().map(|source| {
|
|
let components: &[CatalogComponentId] = match action {
|
|
PrivilegedAction::InstallProxifyre => &[
|
|
CatalogComponentId::Proxifyre,
|
|
CatalogComponentId::WindowsPacketFilter,
|
|
CatalogComponentId::VcRuntime,
|
|
],
|
|
PrivilegedAction::UpdateProxifyre => &[CatalogComponentId::Proxifyre],
|
|
PrivilegedAction::InstallSingBox => {
|
|
&[CatalogComponentId::SingBox, CatalogComponentId::Winsw]
|
|
}
|
|
PrivilegedAction::UpdateSingBox => &[CatalogComponentId::SingBox],
|
|
_ => &[],
|
|
};
|
|
let assets = components
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, component_id)| PlannedAssetFingerprint {
|
|
component_id: *component_id,
|
|
version: if index == 0 {
|
|
"1.2.3".to_string()
|
|
} else {
|
|
format!("1.2.{}", index + 3)
|
|
},
|
|
name: format!("component-{index}.zip"),
|
|
sha256: format!("{:x}", index + 13).repeat(64)[..64].to_string(),
|
|
size: 42 + index as u64,
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let asset = assets.first().expect("package asset");
|
|
let origin_proof =
|
|
(source == InstalledPackageSource::Cache).then(|| PlannedGithubOriginProof {
|
|
repository: match action.component() {
|
|
ManagedComponent::Proxifyre => "wiresock/proxifyre",
|
|
ManagedComponent::SingBox => "SagerNet/sing-box",
|
|
}
|
|
.to_string(),
|
|
stable_tag: "v1.2.3".to_string(),
|
|
release_id: 11,
|
|
asset_id: 22,
|
|
asset_name: asset.name.clone(),
|
|
size: asset.size,
|
|
sha256_from_api: asset.sha256.clone(),
|
|
});
|
|
PlannedPackageFingerprint {
|
|
source,
|
|
version: "1.2.3".to_string(),
|
|
assets,
|
|
origin_proof,
|
|
}
|
|
});
|
|
ResolvedActionPlan::new(action, package, "a".repeat(64), "e".repeat(64), None)
|
|
}
|
|
|
|
fn cutover_plan(mode: PrivilegedCutoverMode) -> ResolvedActionPlan {
|
|
let package = (mode == PrivilegedCutoverMode::New).then(|| {
|
|
plan(PrivilegedAction::InstallProxifyre)
|
|
.package
|
|
.expect("bundle")
|
|
});
|
|
ResolvedActionPlan::new(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
package,
|
|
"a".repeat(64),
|
|
"e".repeat(64),
|
|
Some(PrivilegedCutoverContext {
|
|
mode,
|
|
cutover_id: (mode == PrivilegedCutoverMode::Recovery)
|
|
.then(|| "11111111-1111-4111-8111-111111111111".to_string()),
|
|
initiating_startup_session_id: (mode == PrivilegedCutoverMode::New)
|
|
.then(|| "22222222-2222-4222-8222-222222222222".to_string()),
|
|
evidence_fingerprint: "d".repeat(64),
|
|
user_evidence: None,
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn recovery_probe_plan(inventory_fingerprint: &str) -> ResolvedActionPlan {
|
|
ResolvedActionPlan::new(
|
|
PrivilegedAction::CutoverProxifyre,
|
|
None,
|
|
inventory_fingerprint.to_string(),
|
|
"e".repeat(64),
|
|
Some(PrivilegedCutoverContext {
|
|
mode: PrivilegedCutoverMode::Recovery,
|
|
cutover_id: None,
|
|
initiating_startup_session_id: None,
|
|
evidence_fingerprint: cutover_recovery_probe_fingerprint(inventory_fingerprint),
|
|
user_evidence: None,
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn cleanup_plan() -> ResolvedActionPlan {
|
|
ResolvedActionPlan::new(
|
|
PrivilegedAction::CleanupProxifyreQuarantine,
|
|
None,
|
|
"a".repeat(64),
|
|
"e".repeat(64),
|
|
Some(PrivilegedCutoverContext {
|
|
mode: PrivilegedCutoverMode::Cleanup,
|
|
cutover_id: Some("11111111-1111-4111-8111-111111111111".to_string()),
|
|
evidence_fingerprint: "c".repeat(64),
|
|
initiating_startup_session_id: None,
|
|
user_evidence: Some(ComponentCutoverUserEvidence {
|
|
schema_version: CUTOVER_USER_EVIDENCE_SCHEMA_VERSION,
|
|
cutover_id: "11111111-1111-4111-8111-111111111111".to_string(),
|
|
startup_session_id: "33333333-3333-4333-8333-333333333333".to_string(),
|
|
current_inventory_fingerprint: "a".repeat(64),
|
|
route_smoke_confirmed: true,
|
|
observed_at_epoch_seconds: 700,
|
|
confirmed_at_epoch_seconds: Some(701),
|
|
}),
|
|
}),
|
|
)
|
|
}
|
|
|
|
fn plan_with_source(
|
|
action: PrivilegedAction,
|
|
source: InstalledPackageSource,
|
|
) -> ResolvedActionPlan {
|
|
let mut value = plan(action);
|
|
value.package.as_mut().expect("package action").source = source;
|
|
value
|
|
}
|
|
|
|
trait TestActionPackage {
|
|
fn requires_test_package(self) -> Option<InstalledPackageSource>;
|
|
}
|
|
|
|
impl TestActionPackage for PrivilegedAction {
|
|
fn requires_test_package(self) -> Option<InstalledPackageSource> {
|
|
match self {
|
|
Self::InstallProxifyre | Self::InstallSingBox => Some(InstalledPackageSource::Bundled),
|
|
Self::UpdateProxifyre | Self::UpdateSingBox => Some(InstalledPackageSource::Cache),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rewrite_request_field(path: &Path, name: &str, value: Value) {
|
|
let mut json: Value =
|
|
serde_json::from_slice(&fs::read(path).expect("read request")).expect("request json");
|
|
json.as_object_mut()
|
|
.expect("request object")
|
|
.insert(name.to_string(), value);
|
|
fs::write(path, serde_json::to_vec(&json).expect("encode request")).expect("write request");
|
|
}
|
|
|
|
fn valid_receipt(root: &CanonicalComponentRoot) -> InstallReceipt {
|
|
InstallReceipt {
|
|
schema_version: INSTALL_RECEIPT_SCHEMA_VERSION,
|
|
manager: InstallReceiptManager::ProxyWarden,
|
|
component: root.component(),
|
|
install_root: root.path().display().to_string(),
|
|
service_name: "ProxiFyreService".to_string(),
|
|
version: "2.2.1".to_string(),
|
|
source: InstalledPackageSource::Bundled,
|
|
asset: InstalledAssetReceipt {
|
|
name: "ProxiFyre.zip".to_string(),
|
|
sha256: "a".repeat(64),
|
|
size: 100,
|
|
},
|
|
installed_files: vec![InstalledFileReceipt {
|
|
relative_path: "ProxiFyre.exe".to_string(),
|
|
sha256: "b".repeat(64),
|
|
size: 10,
|
|
}],
|
|
installed_at: 1_000,
|
|
windows_packet_filter: None,
|
|
}
|
|
}
|