Release v2.0.0
CI / Windows baseline (push) Canceled after 0s

This commit is contained in:
2026-09-10 20:59:52 +03:00
parent 9c987df6e9
commit efda8eb98f
142 changed files with 68308 additions and 9333 deletions
+5 -1
View File
@@ -48,7 +48,11 @@ impl ProxiFyreAdapter {
proxies.push(ProxiFyreProxy {
app_names,
socks5_proxy_endpoint: format!("{}:{}", target.host, target.port),
socks5_proxy_endpoint: if target.host.contains(':') {
format!("[{}]:{}", target.host, target.port)
} else {
format!("{}:{}", target.host, target.port)
},
supported_protocols: protocols_for_profile(profile),
});
}
+85 -117
View File
@@ -1,8 +1,8 @@
use crate::models::{LocalSingBoxConfig, SubscriptionCache, SubscriptionServer};
use crate::process::command_no_window;
use crate::process::run_fixed_process;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::{env, fs, fs::OpenOptions, io::Write, path::Path};
use std::{env, fs, path::Path, time::Duration};
pub const SINGBOX_ADAPTER_ID: &str = "singbox";
pub const SINGBOX_OUTPUT_FILE: &str = "sing-box-config.json";
@@ -41,31 +41,24 @@ impl SingBoxAdapter {
where
C: SingBoxConfigChecker + ?Sized,
{
let selected_server = request
.config
.selected_server_id
.as_deref()
.and_then(|id| {
request
.subscription_cache
.servers
.iter()
.find(|server| server.id == id)
})
.or_else(|| {
let tag = request.config.selected_server_tag.as_deref()?;
request
.subscription_cache
.servers
.iter()
.find(|server| server.tag == tag)
})
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedServer,
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
)
})?;
let selected_server = if let Some(id) = request.config.selected_server_id.as_deref() {
request
.subscription_cache
.servers
.iter()
.find(|server| server.id == id)
} else {
let mut matches = request.subscription_cache.servers.iter().filter(|server| {
Some(server.tag.as_str()) == request.config.selected_server_tag.as_deref()
});
matches.next().filter(|_| matches.next().is_none())
}
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedServer,
"Сервер Local sing-box не выбран или отсутствует в текущей подписке",
)
})?;
let vpn_outbound = selected_outbound(
&request.subscription_cache.config,
selected_server,
@@ -213,70 +206,50 @@ impl SingBoxConfigChecker for SingBoxCommandChecker {
uuid::Uuid::new_v4().hyphenated()
));
{
let mut config_file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&config_path)
.map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось создать временный конфиг sing-box '{}': {error}",
config_path.display()
),
)
})?;
let write_result = config_file.write_all(config_json.as_bytes());
drop(config_file);
if let Err(error) = write_result {
let _ = fs::remove_file(&config_path);
return Err(SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось записать временный конфиг sing-box '{}': {error}",
config_path.display()
),
));
struct TemporaryConfig(std::path::PathBuf);
impl Drop for TemporaryConfig {
fn drop(&mut self) {
let _ = fs::remove_file(&self.0);
}
}
let output = command_no_window(binary_path)
.arg("check")
.arg("-c")
.arg(&config_path)
.output()
.map_err(|error| {
let _ = fs::remove_file(&config_path);
let _temporary = TemporaryConfig(config_path.clone());
crate::safe_fs::write_restricted_atomic(&config_path, config_json.as_bytes()).map_err(
|_| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!(
"Не удалось выполнить '{} check': {error}",
binary_path.display()
),
"Не удалось безопасно создать временный конфиг sing-box",
)
})?;
let _ = fs::remove_file(&config_path);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let message = command_message(&stdout, &stderr);
if !output.status.success() {
return Err(SingBoxConfigError::new(
},
)?;
// Checker output can contain credentials from the outbound. The bounded
// native process runner discards both streams instead of exposing them.
let status = run_fixed_process(
binary_path,
&[
"check".into(),
"-c".into(),
config_path.as_os_str().to_owned(),
],
Duration::from_secs(30),
)
.map_err(|error| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::CheckFailed,
format!("Проверка sing-box не прошла: {message}"),
));
if error.kind() == std::io::ErrorKind::TimedOut {
"Проверка sing-box превысила 30 секунд"
} else {
"Не удалось выполнить проверку sing-box"
},
)
})?;
if !status.success() {
return Err(SingBoxConfigError::new(SingBoxConfigErrorKind::CheckFailed,
"sing-box отклонил конфигурацию выбранного сервера. Обновите подписку или выберите другой сервер."));
}
Ok(SingBoxCheckResult {
checked: true,
success: true,
message: if message.is_empty() {
"Проверка sing-box прошла успешно".to_string()
} else {
message
},
message: "Проверка sing-box прошла успешно".to_string(),
})
}
}
@@ -295,32 +268,39 @@ fn selected_outbound(
"В cache подписки нет outbounds",
)
})?;
let outbound = outbounds
.iter()
.find(|outbound| {
let tag_matches = outbound
let outbound = if selected_server.id.starts_with("pw-") {
outbounds.iter().find(|outbound| {
crate::subscription::outbound_server_id(outbound) == selected_server.id
})
} else {
// Legacy endpoint IDs are readable only when they identify exactly one outbound.
let mut matches = outbounds.iter().filter(|outbound| {
outbound
.get("tag")
.and_then(Value::as_str)
.is_some_and(|tag| tag.trim() == selected_server.tag);
let server_matches = outbound
.get("server")
.and_then(Value::as_str)
.is_some_and(|server| server.eq_ignore_ascii_case(&selected_server.server));
let port_matches = outbound
.get("server_port")
.and_then(Value::as_u64)
.is_some_and(|port| port == u64::from(selected_server.server_port));
tag_matches && server_matches && port_matches
})
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedOutbound,
format!(
"Outbound не найден: {} ({}:{})",
selected_server.tag, selected_server.server, selected_server.server_port
),
)
})?;
.is_some_and(|tag| {
crate::models::decode_percent_encoded_utf8(tag).trim() == selected_server.tag
})
&& outbound.get("type").and_then(Value::as_str)
== Some(selected_server.server_type.as_str())
&& outbound
.get("server")
.and_then(Value::as_str)
.is_some_and(|host| host.eq_ignore_ascii_case(&selected_server.server))
&& outbound.get("server_port").and_then(Value::as_u64)
== Some(u64::from(selected_server.server_port))
});
matches.next().filter(|_| matches.next().is_none())
}
.ok_or_else(|| {
SingBoxConfigError::new(
SingBoxConfigErrorKind::MissingSelectedOutbound,
format!(
"Outbound не найден: {} ({}:{})",
selected_server.tag, selected_server.server, selected_server.server_port
),
)
})?;
let outbound_type = outbound
.get("type")
.and_then(Value::as_str)
@@ -359,15 +339,3 @@ fn selected_outbound(
Ok(outbound)
}
fn command_message(stdout: &str, stderr: &str) -> String {
let stdout = stdout.trim();
let stderr = stderr.trim();
match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout.to_string(),
(true, false) => stderr.to_string(),
(false, false) => format!("{stdout}\n{stderr}"),
}
}
+5 -71
View File
@@ -1,89 +1,23 @@
//! Administrator-state detection and explicit UAC restart boundary.
use crate::command_dto::{AdminStatusResponse, CommandError};
use crate::powershell::{
escape_single as escape_powershell_single, is_elevated as is_running_elevated,
output_message as powershell_output_message, run_command as run_powershell_command,
};
use std::env;
use crate::command_dto::AdminStatusResponse;
use crate::process::is_process_elevated;
pub fn admin_status() -> AdminStatusResponse {
let is_windows = cfg!(windows);
let is_elevated = is_running_elevated();
let is_elevated = is_process_elevated();
let message = if !is_windows {
"Проверка прав администратора нужна только в Windows.".to_string()
} else if is_elevated {
"ProxyWarden уже запущен от имени администратора.".to_string()
} else {
"Для установки компонентов и управления службами можно перезапустить ProxyWarden от имени администратора один раз.".to_string()
"Права администратора будут запрошены отдельно для выбранного действия.".to_string()
};
AdminStatusResponse {
is_windows,
is_elevated,
can_restart_elevated: is_windows && !is_elevated,
can_restart_elevated: false,
message,
}
}
pub(crate) fn launch_app_as_admin() -> Result<(), CommandError> {
if !cfg!(windows) {
return Err(CommandError::new(
"admin_restart_unsupported",
"Перезапуск от имени администратора доступен только в Windows.",
));
}
if is_running_elevated() {
return Ok(());
}
let exe_path = env::current_exe().map_err(|error| {
CommandError::new(
"admin_restart_failed",
format!("Не удалось определить путь текущего приложения: {error}"),
)
})?;
let working_dir = env::current_dir().ok();
let working_dir_arg = working_dir
.as_ref()
.map(|path| {
format!(
" -WorkingDirectory '{}'",
escape_powershell_single(&path.display().to_string())
)
})
.unwrap_or_default();
let script = format!(
r#"
$ErrorActionPreference = 'Stop'
try {{
Start-Process -FilePath '{}' -Verb RunAs{}
exit 0
}} catch {{
Write-Error ($_ | Out-String)
exit 1
}}
"#,
escape_powershell_single(&exe_path.display().to_string()),
working_dir_arg
);
let output = run_powershell_command(&script).map_err(|error| {
CommandError::new(
"admin_restart_failed",
format!("Не удалось запросить права администратора: {error}"),
)
})?;
if output.status.success() {
return Ok(());
}
Err(CommandError::new(
"admin_restart_failed",
powershell_output_message(
&output,
"Перезапуск от имени администратора отменен или не был запущен.",
),
))
}
+180 -251
View File
@@ -22,7 +22,7 @@ use crate::safe_fs;
use crate::storage::JsonStorage;
use crate::validation::{normalize_profile, normalize_target, ValidationError};
use serde::{Deserialize, Serialize};
use std::{fs, path::Path};
use std::path::Path;
use thiserror::Error;
const LOCAL_SINGBOX_TARGET_ID: &str = "local-singbox";
@@ -37,10 +37,12 @@ pub enum ApplyRouteMode {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyConfigurationInput {
#[serde(default)]
pub expected_revision: Option<String>,
pub route_mode: ApplyRouteMode,
pub profile: ProfileInput,
pub external_target: Option<TargetInput>,
#[serde(default = "default_true")]
#[serde(default)]
pub disable_other_profiles: bool,
}
@@ -65,6 +67,7 @@ pub enum ApplyPhaseStatus {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ApplyConfigurationResult {
pub saved_state: Option<crate::command_dto::SavedStateResponse>,
pub success: bool,
pub changed: bool,
pub partial_state: bool,
@@ -128,6 +131,19 @@ pub fn apply_configuration(
input: ApplyConfigurationInput,
services: ApplyServices<'_>,
) -> Result<ApplyConfigurationResult, ApplyFlowError> {
let read_guard = crate::configuration_transaction::read_guard(storage)
.map_err(|error| storage_error("configuration_locked", error))?;
if let Some(expected) = &input.expected_revision {
if crate::configuration_transaction::revision_locked(storage)
.map_err(|e| storage_error("configuration_read_failed", e))?
!= *expected
{
return Err(ApplyFlowError::failure(
"configuration_changed",
"Настройки изменились. Обновите сохранённое состояние перед применением.",
));
}
}
let mut phases = Vec::new();
let old_profiles = storage
.read_profiles()
@@ -142,6 +158,18 @@ pub fn apply_configuration(
proxy_config,
singbox_config,
} = prepare_apply(storage, input, &services)?;
let revision = crate::configuration_transaction::revision_locked(storage)
.map_err(|error| storage_error("configuration_read_failed", error))?;
drop(read_guard);
if let (Some(generated), Some(detected)) = (&singbox_config, &services.detected_singbox) {
services
.checker
.check_config(&detected.executable_path, &generated.contents)
.map_err(|error| ApplyFlowError::failure("singbox_preflight_failed", error.message))?;
}
let transaction =
crate::configuration_transaction::ConfigurationTransaction::begin(storage, Some(&revision))
.map_err(|error| storage_error("configuration_changed", error))?;
phases.push(phase(
"preflight",
ApplyPhaseStatus::Succeeded,
@@ -156,132 +184,95 @@ pub fn apply_configuration(
let singbox_path = singbox_config
.as_ref()
.map(|_| storage.paths().generated_dir.join(SINGBOX_OUTPUT_FILE));
let old_proxy_contents = fs::read(&proxy_path).ok();
let old_singbox_contents = singbox_path.as_ref().and_then(|path| fs::read(path).ok());
let rollback_state = RollbackState {
storage,
old_profiles: &old_profiles,
old_targets: &old_targets,
proxy_path: &proxy_path,
old_proxy_contents: old_proxy_contents.as_deref(),
singbox_path: singbox_path.as_deref(),
old_singbox_contents: old_singbox_contents.as_deref(),
};
if let Err(error) = storage.write_targets(&targets) {
let rollback = rollback_source(storage, &old_profiles, &old_targets);
let staged = (|| {
storage
.write_targets(&targets)
.map_err(|e| storage_error("targets_write_failed", e))?;
storage
.write_profiles(&profiles)
.map_err(|e| storage_error("profiles_write_failed", e))?;
phases.push(phase(
"source-state",
ApplyPhaseStatus::Failed,
"Не удалось сохранить targets.",
));
phases.push(rollback_phase(&rollback));
return Ok(failed_result(
"targets_write_failed",
format!("Не удалось сохранить цели: {error}"),
rollback.is_err(),
&proxy_path,
singbox_path.as_deref(),
phases,
));
}
if let Err(error) = storage.write_profiles(&profiles) {
let rollback = rollback_source(storage, &old_profiles, &old_targets);
phases.push(phase(
"source-state",
ApplyPhaseStatus::Failed,
"Не удалось сохранить profiles.",
));
phases.push(rollback_phase(&rollback));
return Ok(failed_result(
"profiles_write_failed",
format!("Не удалось сохранить профили: {error}"),
rollback.is_err(),
&proxy_path,
singbox_path.as_deref(),
phases,
));
}
phases.push(phase(
"source-state",
ApplyPhaseStatus::Succeeded,
"Profiles и targets сохранены.",
));
if let (Some(generated), Some(path)) = (singbox_config.as_ref(), singbox_path.as_ref()) {
if let Err(error) = safe_fs::write_with_backup(path, generated.contents.as_bytes()) {
return Ok(rollback_after_failure(
&rollback_state,
"singbox_config_write_failed",
format!("Не удалось записать generated sing-box config: {error}"),
"singbox-config",
phases,
));
}
phases.push(phase(
"singbox-config",
ApplyPhaseStatus::Succeeded,
"Generated sing-box config записан; служба не перезапускалась.",
"Profiles и targets сохранены.",
));
} else {
phases.push(phase(
"singbox-config",
ApplyPhaseStatus::Skipped,
"External SOCKS5 не использует Local sing-box.",
));
}
if let Err(error) = safe_fs::write_with_backup(&proxy_path, proxy_config.contents.as_bytes()) {
return Ok(rollback_after_failure(
&rollback_state,
"proxifyre_config_write_failed",
format!("Не удалось записать generated ProxiFyre config: {error}"),
"proxifyre-config",
phases,
));
}
phases.push(phase(
"proxifyre-config",
ApplyPhaseStatus::Succeeded,
"Generated ProxiFyre config записан.",
));
let helper_result = match services.helper.apply_proxy_config(HelperApplyRequest {
adapter_id: &proxy_config.adapter_id,
config_path: &proxy_path,
config_contents: &proxy_config.contents,
}) {
Ok(result) if result.success => result,
Ok(result) => {
return Ok(rollback_after_failure(
&rollback_state,
if let (Some(generated), Some(path)) = (&singbox_config, &singbox_path) {
safe_fs::write_restricted_with_backup(path, generated.contents.as_bytes())
.map_err(|e| storage_error("singbox_config_write_failed", e))?;
}
safe_fs::write_restricted_with_backup(&proxy_path, proxy_config.contents.as_bytes())
.map_err(|e| storage_error("proxifyre_config_write_failed", e))?;
let result = services
.helper
.apply_proxy_config(HelperApplyRequest {
adapter_id: &proxy_config.adapter_id,
config_path: &proxy_path,
config_contents: &proxy_config.contents,
})
.map_err(|e| ApplyFlowError::failure(e.code, e.message))?;
if !result.success {
return Err(ApplyFlowError::failure(
"proxifyre_apply_failed",
result.message,
"runtime-apply",
phases,
));
}
crate::route_state::record_prepared_locked(
storage,
crate::privileged_jobs::ManagedComponent::Proxifyre,
)
.map_err(|e| storage_error("prepared_state_write_failed", e))?;
if singbox_config.is_some() {
crate::route_state::record_prepared_locked(
storage,
crate::privileged_jobs::ManagedComponent::SingBox,
)
.map_err(|e| storage_error("prepared_state_write_failed", e))?;
}
Ok(result)
})();
let (helper_result, committed_revision, artifacts) = match staged {
Ok(result) => {
let artifacts = crate::route_state::read_status_locked(storage)
.map_err(|e| storage_error("prepared_state_read_failed", e))?;
let revision = transaction
.commit_with_revision()
.map_err(|error| storage_error("configuration_commit_failed", error))?;
(result, revision, artifacts)
}
Err(error) => {
return Ok(rollback_after_failure(
&rollback_state,
&error.code,
error.message,
"runtime-apply",
let rollback = transaction.abort();
phases.push(phase(
"rollback",
if rollback.is_ok() {
ApplyPhaseStatus::RolledBack
} else {
ApplyPhaseStatus::Failed
},
if rollback.is_ok() {
"Предыдущие настройки и конфиги восстановлены."
} else {
"Восстановление не завершено; новые операции заблокированы до recovery."
},
));
return Ok(failed_result(
if rollback.is_ok() {
error.code()
} else {
"configuration_recovery_required"
},
error.to_string(),
rollback.is_err(),
&proxy_path,
singbox_path.as_deref(),
phases,
));
}
};
phases.push(phase(
"runtime-apply",
ApplyPhaseStatus::Succeeded,
"ProxiFyre config применён без управления службой.",
));
phases.push(phase(
"service-control",
ApplyPhaseStatus::Skipped,
"Apply не запускает, не останавливает и не перезапускает службы.",
"Apply не управляет службами.",
));
let mut restart_required = Vec::new();
if services.detected_proxyfier.is_some() {
restart_required.push(ComponentId::Proxyfier);
@@ -320,6 +311,19 @@ pub fn apply_configuration(
}
Ok(ApplyConfigurationResult {
saved_state: Some(crate::command_dto::SavedStateResponse {
artifacts,
revision: committed_revision,
profiles: profiles
.iter()
.map(crate::command_dto::ProfileDto::from)
.collect(),
targets: targets
.iter()
.map(crate::command_dto::TargetDto::from)
.collect(),
generated_config_path: proxy_path.display().to_string(),
}),
success: true,
changed: source_changed || helper_result.changed,
partial_state: false,
@@ -351,62 +355,72 @@ fn prepare_apply(
));
}
let mut profile_input = input.profile;
let mut profiles = storage
.read_profiles()
.map_err(|error| storage_error("profiles_read_failed", error))?;
let mut targets = storage
.read_targets()
.map_err(|error| storage_error("targets_read_failed", error))?;
let singbox_config = match input.route_mode {
ApplyRouteMode::External => {
let target_input = input.external_target.ok_or_else(|| {
ApplyFlowError::failure(
"external_target_missing",
"Для external маршрута требуется SOCKS5 target.",
)
})?;
let target = normalize_target(target_input).map_err(ApplyFlowError::validation)?;
profile_input.target_id = target.id.clone();
upsert_target(&mut targets, target);
None
}
ApplyRouteMode::LocalSingbox => {
let config = storage
.read_local_singbox_config()
.map_err(|error| storage_error("singbox_config_read_failed", error))?;
let cache = storage
.read_singbox_subscription_cache()
.map_err(|error| storage_error("singbox_cache_read_failed", error))?
.ok_or_else(|| {
let clearing_profile = !profile_input.enabled && profile_input.items.is_empty();
let singbox_config = if clearing_profile {
None
} else {
match input.route_mode {
ApplyRouteMode::External => {
let target_input = input.external_target.ok_or_else(|| {
ApplyFlowError::failure(
"singbox_subscription_cache_missing",
"Сначала загрузите подписку Local sing-box.",
"external_target_missing",
"Для external маршрута требуется SOCKS5 target.",
)
})?;
profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string();
upsert_target(&mut targets, local_singbox_target(&config));
Some(
services
.singbox_adapter
.generate_config(
SingBoxGenerationRequest::new(
&config,
&cache,
services
.detected_singbox
.as_ref()
.map(|detected| detected.executable_path.as_path()),
),
services.checker,
)
.map_err(|error| {
ApplyFlowError::failure("singbox_preflight_failed", error.message)
})?,
)
let mut target =
normalize_target(target_input).map_err(ApplyFlowError::validation)?;
let shared = profiles.iter().any(|existing| {
Some(existing.id.as_str()) != profile_input.id.as_deref()
&& existing.target_id == target.id
});
if shared
&& targets
.iter()
.any(|existing| existing.id == target.id && existing != &target)
{
target.id = format!("target-{}", uuid::Uuid::new_v4());
}
profile_input.target_id = target.id.clone();
upsert_target(&mut targets, target);
None
}
ApplyRouteMode::LocalSingbox => {
let config = storage
.read_local_singbox_config()
.map_err(|error| storage_error("singbox_config_read_failed", error))?;
let cache = storage
.read_singbox_subscription_cache()
.map_err(|error| storage_error("singbox_cache_read_failed", error))?
.ok_or_else(|| {
ApplyFlowError::failure(
"singbox_subscription_cache_missing",
"Сначала загрузите подписку Local sing-box.",
)
})?;
profile_input.target_id = LOCAL_SINGBOX_TARGET_ID.to_string();
upsert_target(&mut targets, local_singbox_target(&config));
Some(
services
.singbox_adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, None),
services.checker,
)
.map_err(|error| {
ApplyFlowError::failure("singbox_preflight_failed", error.message)
})?,
)
}
}
};
let profile = normalize_profile(profile_input).map_err(ApplyFlowError::validation)?;
let mut profiles = storage
.read_profiles()
.map_err(|error| storage_error("profiles_read_failed", error))?;
if input.disable_other_profiles {
for existing in &mut profiles {
if existing.id != profile.id {
@@ -415,6 +429,14 @@ fn prepare_apply(
}
}
upsert_profile(&mut profiles, profile);
if !profiles.iter().any(|profile| profile.enabled)
&& proxyfier_component_from_detection(services.detected_proxyfier.as_ref()).running
{
return Err(ApplyFlowError::failure(
"stop_before_clearing_route",
"Сначала явно остановите ProxiFyre, затем примените удаление последних правил.",
));
}
let components = vec![
proxyfier_component_from_detection(services.detected_proxyfier.as_ref()),
@@ -462,96 +484,6 @@ fn upsert_target(targets: &mut Vec<Target>, target: Target) {
}
}
struct RollbackState<'a> {
storage: &'a JsonStorage,
old_profiles: &'a [Profile],
old_targets: &'a [Target],
proxy_path: &'a Path,
old_proxy_contents: Option<&'a [u8]>,
singbox_path: Option<&'a Path>,
old_singbox_contents: Option<&'a [u8]>,
}
fn rollback_after_failure(
state: &RollbackState<'_>,
code: &str,
message: String,
failed_phase: &str,
mut phases: Vec<ApplyPhase>,
) -> ApplyConfigurationResult {
phases.push(phase(failed_phase, ApplyPhaseStatus::Failed, &message));
let source_rollback = rollback_source(state.storage, state.old_profiles, state.old_targets);
let proxy_rollback = restore_generated(state.proxy_path, state.old_proxy_contents);
let singbox_rollback = state
.singbox_path
.map(|path| restore_generated(path, state.old_singbox_contents))
.unwrap_or(Ok(()));
let rollback_ok = source_rollback.is_ok() && proxy_rollback.is_ok() && singbox_rollback.is_ok();
phases.push(if rollback_ok {
phase(
"rollback",
ApplyPhaseStatus::RolledBack,
"Source state и generated artifacts восстановлены.",
)
} else {
phase(
"rollback",
ApplyPhaseStatus::Failed,
"Rollback завершился не полностью; проверьте файлы config/generated.",
)
});
failed_result(
code,
message,
!rollback_ok,
state.proxy_path,
state.singbox_path,
phases,
)
}
fn rollback_source(
storage: &JsonStorage,
profiles: &[Profile],
targets: &[Target],
) -> Result<(), String> {
let targets_result = storage
.write_targets(targets)
.map_err(|error| error.to_string());
let profiles_result = storage
.write_profiles(profiles)
.map_err(|error| error.to_string());
targets_result.and(profiles_result)
}
fn restore_generated(path: &Path, previous: Option<&[u8]>) -> Result<(), String> {
match previous {
Some(contents) => {
safe_fs::write_with_backup(path, contents).map_err(|error| error.to_string())
}
None => match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.to_string()),
},
}
}
fn rollback_phase(result: &Result<(), String>) -> ApplyPhase {
match result {
Ok(()) => phase(
"rollback",
ApplyPhaseStatus::RolledBack,
"Source state восстановлен.",
),
Err(error) => phase(
"rollback",
ApplyPhaseStatus::Failed,
format!("Не удалось полностью восстановить source state: {error}"),
),
}
}
fn failed_result(
code: &str,
message: String,
@@ -561,6 +493,7 @@ fn failed_result(
phases: Vec<ApplyPhase>,
) -> ApplyConfigurationResult {
ApplyConfigurationResult {
saved_state: None,
success: false,
changed: false,
partial_state,
@@ -588,7 +521,3 @@ fn phase(
fn storage_error(code: &str, error: std::io::Error) -> ApplyFlowError {
ApplyFlowError::failure(code, format!("Ошибка storage: {error}"))
}
fn default_true() -> bool {
true
}
+300 -11
View File
@@ -4,6 +4,11 @@
//! camelCase contract exposed to the React webview.
use crate::adapters::singbox::SingBoxCheckResult;
use crate::component_catalog::ComponentId as CatalogComponentId;
use crate::component_packages::{
ComponentInstallSource, ComponentUpdateState, ComponentUpdateStatus, PackageSource,
UpdateCheckTrust, UpdateFreshness,
};
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, ComponentState, ComponentStatus, LocalSingBoxConfig,
Profile, ProfileInput, ProfileItem, ProfileItemInput, ProfileItemType, Protocol, ProxyProtocol,
@@ -74,6 +79,8 @@ pub struct StatusResponse {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SavedStateResponse {
pub artifacts: Vec<crate::route_state::ArtifactStatus>,
pub revision: String,
pub profiles: Vec<ProfileDto>,
pub targets: Vec<TargetDto>,
pub generated_config_path: String,
@@ -83,6 +90,7 @@ pub struct SavedStateResponse {
#[serde(rename_all = "camelCase")]
pub struct StartupSnapshotResponse {
pub admin_status: AdminStatusResponse,
pub migration_status: StorageMigrationStatusDto,
pub saved_state: SavedStateResponse,
pub components: Vec<ComponentStatusDto>,
pub proxifyre_setup_status: ProxiFyreSetupStatusDto,
@@ -90,6 +98,18 @@ pub struct StartupSnapshotResponse {
pub singbox_setup_status: SingBoxSetupStatusDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StorageMigrationStatusDto {
pub storage_schema_version: u32,
pub component_layout_version: Option<u32>,
pub outcome: String,
pub changed: bool,
pub blocking: bool,
pub notice_code: Option<String>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxiFyreSetupStatusDto {
@@ -108,22 +128,12 @@ pub struct ProxiFyreSetupItemDto {
pub details: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProxiFyreSetupProgressDto {
pub operation: String,
pub status: String,
pub active_step: Option<String>,
pub percent: u8,
pub message: String,
pub updated_at: Option<String>,
}
pub type SingBoxSetupStatusDto = SingBoxSetupStatus;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalSingBoxStatusResponse {
pub saved_state: SavedStateResponse,
pub config: LocalSingBoxConfigDto,
pub cache: Option<SubscriptionCacheDto>,
pub component: ComponentStatusDto,
@@ -356,6 +366,285 @@ pub struct ComponentStatusDto {
pub actions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentLifecycleResponseDto {
pub component: ComponentStatusDto,
pub changed: bool,
pub reboot_required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ManagedPackageComponentDto {
Proxifyre,
SingBox,
}
impl ManagedPackageComponentDto {
pub(crate) const fn catalog_id(self) -> CatalogComponentId {
match self {
Self::Proxifyre => CatalogComponentId::Proxifyre,
Self::SingBox => CatalogComponentId::SingBox,
}
}
pub(crate) const fn model_id(self) -> ComponentId {
match self {
Self::Proxifyre => ComponentId::Proxyfier,
Self::SingBox => ComponentId::Singbox,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentUpdateFreshnessDto {
NeverChecked,
Fresh,
Stale,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentUpdateStateDto {
Current,
UpdateAvailable,
CheckStale,
UnknownOffline,
Unsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentInstallSourceDto {
Bundled,
Cache,
External,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentPackageSourceDto {
Bundled,
Cache,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentUpdateTrustDto {
Trusted,
MissingIndependentDigest,
MalformedIndependentDigest,
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentPackageRequestDto {
pub component_id: ManagedPackageComponentDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentPackageStatusDto {
pub component_id: ManagedPackageComponentDto,
pub installed_version: Option<String>,
pub bundled_version: String,
pub available_offline_version: String,
pub latest_known_version: Option<String>,
pub last_checked_at: Option<u64>,
pub freshness: ComponentUpdateFreshnessDto,
pub update_state: ComponentUpdateStateDto,
pub install_source: ComponentInstallSourceDto,
pub offline_package_source: ComponentPackageSourceDto,
pub can_install_offline: bool,
pub offline_unavailable_reason: Option<String>,
pub can_download: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentUpdateCheckResponseDto {
pub trust: ComponentUpdateTrustDto,
pub update_available: bool,
pub status: ComponentPackageStatusDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentUpdateDownloadResponseDto {
pub downloaded_version: String,
pub source: ComponentPackageSourceDto,
pub status: ComponentPackageStatusDto,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentUpdateResponseDto {
pub component: ComponentStatusDto,
pub package: ComponentPackageStatusDto,
pub changed: bool,
pub reboot_required: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentCutoverStateDto {
NotNeeded,
Ready,
ManualMigrationRequired,
InProgress,
AwaitingNextStart,
AwaitingRouteSmoke,
CleanupReady,
CleanupPending,
Complete,
RolledBack,
RecoveryRequired,
Blocked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentCutoverModeDto {
ServiceSwitch,
ManualOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentCutoverServiceStateDto {
Running,
Stopped,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentCutoverStatusDto {
pub component_id: ManagedPackageComponentDto,
pub state: ComponentCutoverStateDto,
pub mode: ComponentCutoverModeDto,
pub legacy_version: Option<String>,
pub current_version: Option<String>,
pub bundled_version: Option<String>,
pub original_service_state: Option<ComponentCutoverServiceStateDto>,
pub legacy_path_label: Option<String>,
pub current_path_label: Option<String>,
pub steps: Vec<String>,
pub next_start_verified: bool,
pub route_smoke_confirmed: bool,
pub can_cutover: bool,
pub can_confirm_route_smoke: bool,
pub can_cleanup: bool,
pub disabled_code: Option<String>,
pub disabled_message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentCutoverRequestDto {
pub component_id: ManagedPackageComponentDto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfirmComponentRouteSmokeInputDto {
pub component_id: ManagedPackageComponentDto,
pub confirmed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComponentCutoverResponseDto {
pub status: ComponentCutoverStatusDto,
pub changed: bool,
pub reboot_required: bool,
}
impl TryFrom<&ComponentUpdateStatus> for ComponentPackageStatusDto {
type Error = ();
fn try_from(status: &ComponentUpdateStatus) -> Result<Self, Self::Error> {
let component_id = match status.component_id {
CatalogComponentId::Proxifyre => ManagedPackageComponentDto::Proxifyre,
CatalogComponentId::SingBox => ManagedPackageComponentDto::SingBox,
CatalogComponentId::WindowsPacketFilter
| CatalogComponentId::VcRuntime
| CatalogComponentId::Winsw => return Err(()),
};
Ok(Self {
component_id,
installed_version: status.installed_version.clone(),
bundled_version: status.bundled_version.clone(),
available_offline_version: status.available_offline_version.clone(),
latest_known_version: status.latest_known_version.clone(),
last_checked_at: status.last_checked_at_unix,
freshness: status.freshness.into(),
update_state: status.update_state.into(),
install_source: status.install_source.into(),
offline_package_source: status.offline_package_source.into(),
can_install_offline: status.can_install_offline,
offline_unavailable_reason: status.offline_unavailable_reason.clone(),
can_download: status.can_download,
})
}
}
impl From<UpdateFreshness> for ComponentUpdateFreshnessDto {
fn from(value: UpdateFreshness) -> Self {
match value {
UpdateFreshness::NeverChecked => Self::NeverChecked,
UpdateFreshness::Fresh => Self::Fresh,
UpdateFreshness::Stale => Self::Stale,
}
}
}
impl From<ComponentUpdateState> for ComponentUpdateStateDto {
fn from(value: ComponentUpdateState) -> Self {
match value {
ComponentUpdateState::Current => Self::Current,
ComponentUpdateState::UpdateAvailable => Self::UpdateAvailable,
ComponentUpdateState::CheckStale => Self::CheckStale,
ComponentUpdateState::UnknownOffline => Self::UnknownOffline,
ComponentUpdateState::Unsupported => Self::Unsupported,
}
}
}
impl From<ComponentInstallSource> for ComponentInstallSourceDto {
fn from(value: ComponentInstallSource) -> Self {
match value {
ComponentInstallSource::Bundled => Self::Bundled,
ComponentInstallSource::Cache => Self::Cache,
ComponentInstallSource::External => Self::External,
ComponentInstallSource::None => Self::None,
}
}
}
impl From<PackageSource> for ComponentPackageSourceDto {
fn from(value: PackageSource) -> Self {
match value {
PackageSource::Bundled => Self::Bundled,
PackageSource::Cache => Self::Cache,
}
}
}
impl From<UpdateCheckTrust> for ComponentUpdateTrustDto {
fn from(value: UpdateCheckTrust) -> Self {
match value {
UpdateCheckTrust::Trusted => Self::Trusted,
UpdateCheckTrust::MissingIndependentDigest => Self::MissingIndependentDigest,
UpdateCheckTrust::MalformedIndependentDigest => Self::MalformedIndependentDigest,
UpdateCheckTrust::Unsupported => Self::Unsupported,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ActivityEntryDto {
+1663 -112
View File
File diff suppressed because it is too large Load Diff
+869
View File
@@ -0,0 +1,869 @@
use crate::safe_fs::ensure_no_reparse_ancestors;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use thiserror::Error;
use url::Url;
pub const COMPONENT_CATALOG_SCHEMA_VERSION: u32 = 1;
pub const COMPONENT_CATALOG_FILENAME: &str = "catalog.json";
#[derive(Debug, Error)]
pub enum ComponentCatalogError {
#[error("component catalog JSON is invalid: {0}")]
Json(#[from] serde_json::Error),
#[error("component catalog is invalid: {0}")]
Invalid(String),
#[error("component bundle cannot be read: {0}")]
Io(#[from] io::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TargetArch {
X64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetArch {
X64,
Anycpu,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ComponentId {
Proxifyre,
WindowsPacketFilter,
VcRuntime,
SingBox,
Winsw,
}
impl ComponentId {
pub const ALL: [Self; 5] = [
Self::Proxifyre,
Self::WindowsPacketFilter,
Self::VcRuntime,
Self::SingBox,
Self::Winsw,
];
pub const fn as_str(self) -> &'static str {
match self {
Self::Proxifyre => "proxifyre",
Self::WindowsPacketFilter => "windows-packet-filter",
Self::VcRuntime => "vc-runtime",
Self::SingBox => "sing-box",
Self::Winsw => "winsw",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum InstallRole {
ProxifyreRuntime,
PacketFilterDriver,
VcRuntimePrerequisite,
SingBoxRuntime,
SingBoxServiceWrapper,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ComponentCatalog {
pub schema_version: u32,
pub target_arch: TargetArch,
pub components: Vec<ComponentPackage>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ComponentPackage {
pub id: ComponentId,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product_version: Option<String>,
pub asset_path: String,
pub asset_arch: AssetArch,
pub effective_target: TargetArch,
pub sha256: String,
pub size: u64,
pub source_url: String,
pub license: ComponentLicense,
pub install_role: InstallRole,
pub update_trust_policy: UpdateTrustPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ComponentLicense {
pub id: String,
pub path: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "camelCase",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum UpdateTrustPolicy {
GithubReleaseDigest {
repository: String,
tag_pattern: String,
asset_pattern: String,
require_stable: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
authenticode_publishers: Option<Vec<String>>,
},
BuildTimeOnlyAuthenticode {
allowed_source_hosts: Vec<String>,
asset_pattern: String,
publishers: Vec<String>,
},
BundledOnlyNoIndependentProof {
reason: String,
},
}
pub fn parse_catalog(bytes: &[u8]) -> Result<ComponentCatalog, ComponentCatalogError> {
let catalog: ComponentCatalog = serde_json::from_slice(bytes)?;
validate_catalog(&catalog)?;
Ok(catalog)
}
pub fn validate_bundle(root: &Path) -> Result<ComponentCatalog, ComponentCatalogError> {
ensure_no_reparse_ancestors(root)?;
let catalog_path = root.join(COMPONENT_CATALOG_FILENAME);
require_regular_file(&catalog_path, "catalog")?;
let catalog = parse_catalog(&fs::read(&catalog_path)?)?;
let mut expected_files = HashSet::from([COMPONENT_CATALOG_FILENAME.to_string()]);
for component in &catalog.components {
if !expected_files.insert(component.asset_path.clone()) {
return Err(invalid("two components reference the same asset path"));
}
expected_files.insert(component.license.path.clone());
let asset_path = root.join(relative_path(&component.asset_path));
require_regular_file(&asset_path, "component asset")?;
let metadata = fs::metadata(&asset_path)?;
if metadata.len() != component.size {
return Err(invalid(format!(
"asset size does not match catalog for {}",
component.id.as_str()
)));
}
if sha256_file(&asset_path)? != component.sha256 {
return Err(invalid(format!(
"asset SHA-256 does not match catalog for {}",
component.id.as_str()
)));
}
let license_path = root.join(relative_path(&component.license.path));
require_regular_file(&license_path, "license")?;
if fs::metadata(license_path)?.len() == 0 {
return Err(invalid(format!(
"license file is empty for {}",
component.id.as_str()
)));
}
}
let actual_files = collect_bundle_files(root)?;
if actual_files != expected_files {
let missing = expected_files.difference(&actual_files).count();
let extra = actual_files.difference(&expected_files).count();
return Err(invalid(format!(
"bundle file set does not match catalog (missing: {missing}, extra: {extra})"
)));
}
Ok(catalog)
}
pub fn parse_bundled_catalog_if_present(
root: &Path,
) -> Result<Option<ComponentCatalog>, ComponentCatalogError> {
ensure_no_reparse_ancestors(root)?;
match fs::symlink_metadata(root.join(COMPONENT_CATALOG_FILENAME)) {
Ok(_) => validate_bundle(root).map(Some),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
fn validate_catalog(catalog: &ComponentCatalog) -> Result<(), ComponentCatalogError> {
if catalog.schema_version != COMPONENT_CATALOG_SCHEMA_VERSION {
return Err(invalid("unsupported schemaVersion"));
}
if catalog.target_arch != TargetArch::X64 {
return Err(invalid("targetArch must be x64"));
}
if catalog.components.len() != ComponentId::ALL.len() {
return Err(invalid("catalog must contain exactly five components"));
}
let mut component_ids = HashSet::new();
let mut install_roles = HashSet::new();
let mut asset_paths = HashSet::new();
let mut license_paths = HashSet::new();
for component in &catalog.components {
if !component_ids.insert(component.id) {
return Err(invalid("component IDs must be unique"));
}
if !install_roles.insert(component.install_role) {
return Err(invalid("install roles must be unique"));
}
if !asset_paths.insert(component.asset_path.as_str()) {
return Err(invalid("asset paths must be unique"));
}
if !license_paths.insert(component.license.path.as_str()) {
return Err(invalid("license paths must be unique"));
}
validate_component(component)?;
}
if ComponentId::ALL
.iter()
.any(|component_id| !component_ids.contains(component_id))
{
return Err(invalid("catalog is missing a required component"));
}
Ok(())
}
fn validate_component(component: &ComponentPackage) -> Result<(), ComponentCatalogError> {
let (expected_role, expected_arch) = expected_role_and_arch(component.id);
if component.install_role != expected_role {
return Err(invalid(format!(
"installRole does not match component {}",
component.id.as_str()
)));
}
if component.asset_arch != expected_arch || component.effective_target != TargetArch::X64 {
return Err(invalid(format!(
"asset architecture does not match component {}",
component.id.as_str()
)));
}
if !is_stable_numeric_version(&component.version)
|| component
.file_version
.as_deref()
.is_some_and(|version| !is_stable_numeric_version(version))
|| component
.product_version
.as_deref()
.is_some_and(|version| !is_stable_product_version(version))
{
return Err(invalid(format!(
"version metadata is invalid for {}",
component.id.as_str()
)));
}
validate_relative_path(&component.asset_path, "assetPath")?;
if component.asset_path.split('/').next() != Some(component.id.as_str()) {
return Err(invalid(format!(
"assetPath must be inside the {} directory",
component.id.as_str()
)));
}
validate_relative_path(&component.license.path, "license.path")?;
if component.license.path.split('/').next() != Some(component.id.as_str()) {
return Err(invalid(format!(
"license.path must be inside the {} directory",
component.id.as_str()
)));
}
if component.asset_path == component.license.path {
return Err(invalid("assetPath and license.path must be different"));
}
if !is_valid_sha256(&component.sha256) {
return Err(invalid(format!(
"SHA-256 is invalid for {}",
component.id.as_str()
)));
}
if component.size == 0 {
return Err(invalid(format!(
"asset size must be positive for {}",
component.id.as_str()
)));
}
if !is_valid_license_id(&component.license.id) {
return Err(invalid(format!(
"license ID is invalid for {}",
component.id.as_str()
)));
}
validate_component_contract(component)?;
let source = validate_source_url(&component.source_url)?;
let asset_name = component
.asset_path
.rsplit('/')
.next()
.ok_or_else(|| invalid("assetPath has no filename"))?;
if source
.path_segments()
.and_then(|mut segments| segments.next_back())
!= Some(asset_name)
{
return Err(invalid(format!(
"sourceUrl filename does not match assetPath for {}",
component.id.as_str()
)));
}
validate_official_source(component, &source, asset_name)?;
validate_trust_policy(&component.update_trust_policy, &source, asset_name)?;
Ok(())
}
fn validate_component_contract(component: &ComponentPackage) -> Result<(), ComponentCatalogError> {
let expected_license = match component.id {
ComponentId::Proxifyre => "AGPL-3.0-only",
ComponentId::WindowsPacketFilter => "MIT",
ComponentId::VcRuntime => "LicenseRef-Microsoft-Visual-Cpp-v14-Redistributable-2026",
ComponentId::SingBox => "LicenseRef-Sing-Box-Project",
ComponentId::Winsw => "MIT",
};
if component.license.id != expected_license {
return Err(invalid(format!(
"license ID does not match component {}",
component.id.as_str()
)));
}
let policy_matches_component = match (component.id, &component.update_trust_policy) {
(
ComponentId::Proxifyre,
UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
authenticode_publishers,
},
) => {
repository == "wiresock/proxifyre"
&& tag_pattern == "v*"
&& asset_pattern == "ProxiFyre-v*-x64-signed.zip"
&& *require_stable
&& authenticode_publishers
.as_deref()
.is_some_and(|publishers| {
publishers.len() == 1 && publishers[0] == "The Anti-Cloud Corporation"
})
}
(
ComponentId::WindowsPacketFilter,
UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
authenticode_publishers,
},
) => {
repository == "wiresock/ndisapi"
&& tag_pattern == "v*"
&& asset_pattern == "Windows.Packet.Filter.*.x64.msi"
&& *require_stable
&& authenticode_publishers
.as_deref()
.is_some_and(|publishers| {
publishers.len() == 1 && publishers[0] == "The Anti-Cloud Corporation"
})
}
(
ComponentId::SingBox,
UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
authenticode_publishers,
},
) => {
repository == "SagerNet/sing-box"
&& tag_pattern == "v*"
&& asset_pattern == "sing-box-*-windows-amd64.zip"
&& *require_stable
&& authenticode_publishers.is_none()
}
(
ComponentId::VcRuntime,
UpdateTrustPolicy::BuildTimeOnlyAuthenticode {
allowed_source_hosts,
asset_pattern,
publishers,
},
) => {
allowed_source_hosts.len() == 1
&& allowed_source_hosts[0] == "aka.ms"
&& asset_pattern == "VC_redist.x64.exe"
&& publishers.len() == 1
&& publishers[0] == "Microsoft Corporation"
}
(ComponentId::Winsw, UpdateTrustPolicy::BundledOnlyNoIndependentProof { .. }) => true,
_ => false,
};
if !policy_matches_component {
return Err(invalid(format!(
"trust policy does not match component {}",
component.id.as_str()
)));
}
Ok(())
}
fn validate_official_source(
component: &ComponentPackage,
source: &Url,
asset_name: &str,
) -> Result<(), ComponentCatalogError> {
let expected_repository = match component.id {
ComponentId::Proxifyre => Some("wiresock/proxifyre"),
ComponentId::WindowsPacketFilter => Some("wiresock/ndisapi"),
ComponentId::SingBox => Some("SagerNet/sing-box"),
ComponentId::Winsw => Some("winsw/winsw"),
ComponentId::VcRuntime => None,
};
if let Some(expected_repository) = expected_repository {
if source.host_str() != Some("github.com") {
return Err(invalid(
"component source is not its official GitHub repository",
));
}
let segments = github_release_segments(source)?;
if !segments[0..2]
.join("/")
.eq_ignore_ascii_case(expected_repository)
|| segments[5] != asset_name
|| segments[4].strip_prefix('v').unwrap_or(segments[4]) != component.version
{
return Err(invalid(
"component source is not its pinned official release",
));
}
} else if component.version != "14.51.36247.0"
|| source.as_str() != "https://aka.ms/vs/18/release/14.51.36247/VC_redist.x64.exe"
{
return Err(invalid(
"VC runtime must use the pinned Microsoft 14.51.36247.0 source",
));
}
Ok(())
}
const fn expected_role_and_arch(component_id: ComponentId) -> (InstallRole, AssetArch) {
match component_id {
ComponentId::Proxifyre => (InstallRole::ProxifyreRuntime, AssetArch::X64),
ComponentId::WindowsPacketFilter => (InstallRole::PacketFilterDriver, AssetArch::X64),
ComponentId::VcRuntime => (InstallRole::VcRuntimePrerequisite, AssetArch::X64),
ComponentId::SingBox => (InstallRole::SingBoxRuntime, AssetArch::X64),
ComponentId::Winsw => (InstallRole::SingBoxServiceWrapper, AssetArch::Anycpu),
}
}
fn validate_trust_policy(
policy: &UpdateTrustPolicy,
source: &Url,
asset_name: &str,
) -> Result<(), ComponentCatalogError> {
match policy {
UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
authenticode_publishers,
} => {
if !*require_stable {
return Err(invalid(
"GitHub release policy must require a stable release",
));
}
validate_repository(repository)?;
validate_pattern(tag_pattern, "tagPattern")?;
validate_pattern(asset_pattern, "assetPattern")?;
validate_optional_publishers(authenticode_publishers)?;
if source.host_str() != Some("github.com") {
return Err(invalid("GitHub release source must use github.com"));
}
let segments = github_release_segments(source)?;
if !segments[0..2].join("/").eq_ignore_ascii_case(repository)
|| segments[5] != asset_name
|| !pattern_matches(tag_pattern, segments[4])
|| !pattern_matches(asset_pattern, asset_name)
{
return Err(invalid(
"GitHub source URL does not match repository/tag/asset policy",
));
}
}
UpdateTrustPolicy::BuildTimeOnlyAuthenticode {
allowed_source_hosts,
asset_pattern,
publishers,
} => {
validate_hosts(allowed_source_hosts)?;
validate_pattern(asset_pattern, "assetPattern")?;
validate_publishers(publishers)?;
let source_host = source
.host_str()
.ok_or_else(|| invalid("sourceUrl has no host"))?;
if !allowed_source_hosts
.iter()
.any(|host| host.eq_ignore_ascii_case(source_host))
|| !pattern_matches(asset_pattern, asset_name)
{
return Err(invalid(
"build-time Authenticode policy does not match source asset",
));
}
}
UpdateTrustPolicy::BundledOnlyNoIndependentProof { reason } => {
if reason.trim().is_empty()
|| reason.trim() != reason
|| reason.chars().count() > 240
|| reason.chars().any(char::is_control)
{
return Err(invalid("bundled-only policy must contain a safe reason"));
}
}
}
Ok(())
}
fn github_release_segments(source: &Url) -> Result<Vec<&str>, ComponentCatalogError> {
let segments: Vec<_> = source
.path_segments()
.ok_or_else(|| invalid("GitHub source URL has no path"))?
.collect();
if segments.len() != 6 || segments[2] != "releases" || segments[3] != "download" {
return Err(invalid("GitHub source URL is not a release asset URL"));
}
Ok(segments)
}
fn validate_source_url(raw: &str) -> Result<Url, ComponentCatalogError> {
let parsed = Url::parse(raw).map_err(|_| invalid("sourceUrl is not a valid URL"))?;
if parsed.scheme() != "https"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.port().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
{
return Err(invalid("sourceUrl must be a plain HTTPS official URL"));
}
Ok(parsed)
}
fn validate_repository(repository: &str) -> Result<(), ComponentCatalogError> {
let mut segments = repository.split('/');
let owner = segments.next().unwrap_or_default();
let name = segments.next().unwrap_or_default();
if segments.next().is_some()
|| !is_safe_repository_segment(owner)
|| !is_safe_repository_segment(name)
|| name.ends_with(".git")
{
return Err(invalid("GitHub repository identity is invalid"));
}
Ok(())
}
fn is_safe_repository_segment(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 100
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
&& value != "."
&& value != ".."
}
fn validate_pattern(pattern: &str, field: &str) -> Result<(), ComponentCatalogError> {
if pattern.is_empty()
|| pattern.len() > 160
|| pattern.matches('*').count() > 1
|| pattern.contains(['/', '\\'])
|| pattern.bytes().any(|byte| {
!(byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'*' | b'+'))
})
{
return Err(invalid(format!("{field} is invalid")));
}
Ok(())
}
fn pattern_matches(pattern: &str, value: &str) -> bool {
match pattern.split_once('*') {
Some((prefix, suffix)) => {
value.len() >= prefix.len() + suffix.len()
&& value.starts_with(prefix)
&& value.ends_with(suffix)
}
None => pattern == value,
}
}
/// Validates a discovered GitHub release asset against the immutable policy
/// embedded in the bundled component catalog.
pub fn validate_github_update_asset(
component: &ComponentPackage,
version: &str,
asset_name: &str,
source_url: &str,
) -> Result<(), ComponentCatalogError> {
let UpdateTrustPolicy::GithubReleaseDigest {
repository,
tag_pattern,
asset_pattern,
require_stable,
..
} = &component.update_trust_policy
else {
return Err(invalid("component does not allow GitHub runtime updates"));
};
if !*require_stable || !is_stable_numeric_version(version) {
return Err(invalid("update version is not stable"));
}
validate_relative_path(asset_name, "update asset name")?;
if asset_name.contains('/') || !pattern_matches(asset_pattern, asset_name) {
return Err(invalid("update asset name does not match policy"));
}
let source = validate_source_url(source_url)?;
if source.host_str() != Some("github.com") {
return Err(invalid("update asset is not hosted by GitHub"));
}
let segments = github_release_segments(&source)?;
let tag = segments[4];
if !segments[0..2].join("/").eq_ignore_ascii_case(repository)
|| segments[5] != asset_name
|| !pattern_matches(tag_pattern, tag)
|| tag.strip_prefix('v').unwrap_or(tag) != version
{
return Err(invalid(
"update asset does not match the pinned repository policy",
));
}
Ok(())
}
fn validate_hosts(hosts: &[String]) -> Result<(), ComponentCatalogError> {
let mut unique = HashSet::new();
if hosts.is_empty()
|| hosts.iter().any(|host| {
host.is_empty()
|| host.len() > 253
|| host != &host.to_ascii_lowercase()
|| host.starts_with('.')
|| host.ends_with('.')
|| !host
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.'))
|| !unique.insert(host.as_str())
})
{
return Err(invalid("allowedSourceHosts is invalid"));
}
Ok(())
}
fn validate_optional_publishers(
publishers: &Option<Vec<String>>,
) -> Result<(), ComponentCatalogError> {
if let Some(publishers) = publishers {
validate_publishers(publishers)?;
}
Ok(())
}
fn validate_publishers(publishers: &[String]) -> Result<(), ComponentCatalogError> {
let mut unique = HashSet::new();
if publishers.is_empty()
|| publishers.iter().any(|publisher| {
publisher.trim().is_empty()
|| publisher.trim() != publisher
|| publisher.chars().count() > 128
|| publisher.chars().any(char::is_control)
|| !unique.insert(publisher.as_str())
})
{
return Err(invalid("Authenticode publishers are invalid"));
}
Ok(())
}
fn validate_relative_path(value: &str, field: &str) -> Result<(), ComponentCatalogError> {
if value.is_empty()
|| value.len() > 512
|| value.contains('\\')
|| value.starts_with('/')
|| value.ends_with('/')
|| value.split('/').any(|segment| {
segment.is_empty()
|| segment == "."
|| segment == ".."
|| segment.len() > 128
|| segment.ends_with('.')
|| is_windows_reserved_name(segment)
|| !segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
})
{
return Err(invalid(format!("{field} is not a safe relative path")));
}
Ok(())
}
fn is_windows_reserved_name(segment: &str) -> bool {
let stem = segment.split('.').next().unwrap_or_default();
let upper = stem.to_ascii_uppercase();
matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| upper
.strip_prefix("COM")
.or_else(|| upper.strip_prefix("LPT"))
.is_some_and(|suffix| suffix.len() == 1 && matches!(suffix.as_bytes()[0], b'1'..=b'9'))
}
fn is_valid_sha256(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
}
fn is_valid_license_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 96
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'+' | b'_'))
}
fn is_stable_numeric_version(value: &str) -> bool {
let segments: Vec<_> = value.split('.').collect();
(2..=4).contains(&segments.len())
&& segments.iter().all(|segment| {
!segment.is_empty()
&& segment.len() <= 10
&& segment.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn is_stable_product_version(value: &str) -> bool {
let Some((numeric, metadata)) = value.split_once('+') else {
return is_stable_numeric_version(value);
};
is_stable_numeric_version(numeric)
&& !metadata.is_empty()
&& metadata.len() <= 128
&& !metadata.contains('+')
&& metadata.split('.').all(|segment| {
!segment.is_empty()
&& segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
})
}
fn relative_path(value: &str) -> PathBuf {
value.split('/').collect()
}
fn require_regular_file(path: &Path, label: &str) -> Result<(), ComponentCatalogError> {
ensure_no_reparse_ancestors(path)?;
let metadata = fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(invalid(format!("{label} must be a regular file")));
}
Ok(())
}
fn collect_bundle_files(root: &Path) -> Result<HashSet<String>, ComponentCatalogError> {
ensure_no_reparse_ancestors(root)?;
let mut files = HashSet::new();
let mut directories = vec![root.to_path_buf()];
while let Some(directory) = directories.pop() {
ensure_no_reparse_ancestors(&directory)?;
for entry in fs::read_dir(directory)? {
let entry = entry?;
ensure_no_reparse_ancestors(&entry.path())?;
let file_type = entry.file_type()?;
if file_type.is_symlink() {
return Err(invalid("bundle must not contain symbolic links"));
}
if file_type.is_dir() {
directories.push(entry.path());
} else if file_type.is_file() {
let relative = entry
.path()
.strip_prefix(root)
.map_err(|_| invalid("bundle entry escaped the root directory"))?
.to_string_lossy()
.replace('\\', "/");
validate_relative_path(&relative, "bundle entry")?;
files.insert(relative);
} else {
return Err(invalid("bundle contains a non-regular filesystem entry"));
}
}
}
Ok(files)
}
pub fn sha256_file(path: &Path) -> Result<String, ComponentCatalogError> {
ensure_no_reparse_ancestors(path)?;
let mut file = File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
digest.update(&buffer[..count]);
}
Ok(hex_lower(&digest.finalize()))
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
fn invalid(message: impl Into<String>) -> ComponentCatalogError {
ComponentCatalogError::Invalid(message.into())
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+924
View File
@@ -0,0 +1,924 @@
//! Pure component ownership classification and lifecycle preflight.
//!
//! Detection gathers evidence; this module decides whether ProxyWarden may
//! inspect or mutate a candidate. No component binary is executed here.
use crate::models::ComponentId;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
pub const OWNERSHIP_MISMATCH: &str = "ownership_mismatch";
pub const COMPONENT_INCOMPLETE: &str = "component_incomplete";
pub const FOREIGN_COMPONENT: &str = "foreign_component";
pub const AMBIGUOUS_LEGACY: &str = "ambiguous_legacy";
pub const COMPONENT_MISSING: &str = "component_missing";
pub const LEGACY_IDENTITY_CHANGED: &str = "legacy_identity_changed";
pub const MANUAL_MIGRATION_REQUIRED: &str = "manual_migration_required";
pub const LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT: &str = r"C:\Tools\ProxiFyre";
pub const LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION: &str = "2.2.1";
const LEGACY_PROXIFYRE_PRIMARY_SERVICE: &str = "ProxiFyreService";
const LEGACY_PROXIFYRE_FIXED_VERSION: &str = "2.2.1.0";
const SERVICE_WIN32_OWN_PROCESS: u32 = 0x0000_0010;
const SERVICE_AUTO_START: u32 = 0x0000_0002;
const SERVICE_ERROR_NORMAL: u32 = 0x0000_0001;
const SERVICE_SID_TYPE_NONE: u32 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentClassification {
ManagedCurrent,
ManagedLegacy,
Foreign,
Incomplete,
Missing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CandidateRole {
Current,
Legacy,
ForeignByDefault,
Foreign,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MarkerEvidence {
Valid,
Missing,
Invalid,
NotRequired,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BinaryIdentityEvidence {
KnownPackage,
Unknown,
Mismatch,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServiceEvidence {
pub name: String,
pub status: String,
pub path_name: Option<String>,
pub executable_path: Option<PathBuf>,
pub path_matches_candidate: bool,
pub binary_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentCandidateProbe {
pub component_id: ComponentId,
pub role: CandidateRole,
pub root: PathBuf,
pub root_exists: bool,
pub has_reparse_point: bool,
pub executable_path: Option<PathBuf>,
pub missing_files: Vec<PathBuf>,
pub marker: MarkerEvidence,
pub marker_required: bool,
pub binary_identity: BinaryIdentityEvidence,
pub binary_version: Option<String>,
pub service: Option<ServiceEvidence>,
pub service_required: bool,
pub legacy_identity_complete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InventoryIssue {
pub code: String,
pub message: String,
}
impl InventoryIssue {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentCandidate {
pub component_id: ComponentId,
pub classification: ComponentClassification,
pub role: CandidateRole,
pub root: PathBuf,
pub executable_path: Option<PathBuf>,
pub binary_version: Option<String>,
pub service: Option<ServiceEvidence>,
pub marker: MarkerEvidence,
pub issues: Vec<InventoryIssue>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ComponentInventory {
pub component_id: ComponentId,
pub candidates: Vec<ComponentCandidate>,
pub selected: Option<usize>,
pub issues: Vec<InventoryIssue>,
}
/// Immutable identity retained only by the disabled legacy compatibility
/// helpers until Task 8 removes their implementation. Normal lifecycle routing
/// no longer grants ManagedLegacy Start/Stop/Apply authority.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LegacyComponentIdentity {
component_id: ComponentId,
fingerprint: String,
}
/// Read-only evidence used by the durable cutover coordinator. This is
/// intentionally separate from `ComponentClassification`: legacy discovery
/// and compatibility helpers must not grant migration authority.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LegacyCutoverEvidence {
pub proxifyre_manifest_matches: bool,
pub proxifyre_scm_profile: Option<LegacyProxifyreScmProfile>,
/// SHA-256 over the complete SCM restore snapshot (base config, every
/// CONFIG2 value, security descriptor, and original stable state). The
/// cutover coordinator computes it from the leased snapshot so fields
/// outside the frozen safety profile remain bound to the sealed evidence.
pub proxifyre_scm_snapshot_fingerprint: String,
pub additional_matching_service: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LegacyProxifyreScmProfile {
pub service_type: u32,
pub start_type: u32,
pub error_control: u32,
pub account_name: String,
pub display_name: String,
pub description: String,
pub dependencies: Vec<String>,
pub load_order_group: Option<String>,
pub has_failure_actions: bool,
pub failure_actions_on_non_crash: bool,
pub delayed_auto_start: bool,
pub sid_type: u32,
pub required_privileges: Vec<String>,
pub has_triggers: bool,
pub untrusted_mutation_rights: bool,
}
impl LegacyProxifyreScmProfile {
pub fn matches_frozen_2_2_1_profile(&self) -> bool {
self.service_type == SERVICE_WIN32_OWN_PROCESS
&& self.start_type == SERVICE_AUTO_START
&& self.error_control == SERVICE_ERROR_NORMAL
&& self.account_name.eq_ignore_ascii_case("LocalSystem")
&& self.display_name == "ProxiFyre Service"
&& self.description == "ProxiFyre - SOCKS5 ProxiFyre Service"
&& self.dependencies.is_empty()
&& self.load_order_group.as_deref().is_none_or(str::is_empty)
&& !self.has_failure_actions
&& !self.failure_actions_on_non_crash
&& !self.delayed_auto_start
&& self.sid_type == SERVICE_SID_TYPE_NONE
&& self.required_privileges.is_empty()
&& !self.has_triggers
&& !self.untrusted_mutation_rights
}
}
/// Opaque strict-gate result. External callers can only obtain one through the
/// matcher below; private fields prevent constructing an "approved" enum, and
/// mutation entrypoints do not accept caller-supplied proofs.
///
/// ```compile_fail
/// use proxywarden_lib::component_inventory::LegacyCutoverProof;
///
/// let _forged = LegacyCutoverProof {
/// identity_fingerprint: "forged".to_string(),
/// };
/// ```
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LegacyCutoverProof {
identity_fingerprint: String,
}
impl LegacyCutoverProof {
pub fn fingerprint(&self) -> &str {
&self.identity_fingerprint
}
}
impl ComponentInventory {
pub fn missing(component_id: ComponentId) -> Self {
Self {
component_id,
candidates: Vec::new(),
selected: None,
issues: Vec::new(),
}
}
pub fn selected_candidate(&self) -> Option<&ComponentCandidate> {
self.selected.and_then(|index| self.candidates.get(index))
}
pub fn classification(&self) -> ComponentClassification {
self.selected_candidate()
.map(|candidate| candidate.classification)
.unwrap_or(ComponentClassification::Missing)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InventoryAction {
Install,
Apply,
CheckBinary,
Start,
Stop,
ConfigureFirewall,
Update,
Uninstall,
Cutover,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthorizedActionError<E> {
Denied(InventoryIssue),
Runner(E),
}
pub fn classify_component_candidates(
component_id: ComponentId,
probes: Vec<ComponentCandidateProbe>,
) -> ComponentInventory {
let mut candidates: Vec<_> = probes.into_iter().map(classify_candidate).collect();
candidates.retain(|candidate| candidate.classification != ComponentClassification::Missing);
let current = candidates
.iter()
.position(|candidate| candidate.role == CandidateRole::Current);
let managed_legacy: Vec<_> = candidates
.iter()
.enumerate()
.filter(|(_, candidate)| candidate.classification == ComponentClassification::ManagedLegacy)
.map(|(index, _)| index)
.collect();
let mut issues = Vec::new();
let selected = if let Some(current) = current {
Some(current)
} else if managed_legacy.len() == 1 {
managed_legacy.first().copied()
} else if managed_legacy.len() > 1 {
issues.push(InventoryIssue::new(
AMBIGUOUS_LEGACY,
"Найдено несколько подтвержденных старых установок; автоматический выбор заблокирован.",
));
None
} else {
candidates
.iter()
.position(|candidate| {
candidate.classification == ComponentClassification::Foreign
&& candidate
.issues
.iter()
.any(|issue| issue.code == OWNERSHIP_MISMATCH)
})
.or_else(|| {
candidates.iter().position(|candidate| {
candidate.classification == ComponentClassification::Foreign
})
})
.or_else(|| {
candidates.iter().position(|candidate| {
candidate.classification == ComponentClassification::Incomplete
})
})
};
ComponentInventory {
component_id,
candidates,
selected,
issues,
}
}
pub fn authorize_component_action(
inventory: &ComponentInventory,
action: InventoryAction,
) -> Result<Option<&ComponentCandidate>, InventoryIssue> {
if let Some(issue) = inventory.issues.first() {
return Err(issue.clone());
}
let Some(candidate) = inventory.selected_candidate() else {
return if action == InventoryAction::Install {
Ok(None)
} else {
Err(InventoryIssue::new(
COMPONENT_MISSING,
"Управляемый компонент не найден.",
))
};
};
match candidate.classification {
ComponentClassification::ManagedCurrent => match action {
InventoryAction::Apply
| InventoryAction::CheckBinary
| InventoryAction::Start
| InventoryAction::Stop
| InventoryAction::ConfigureFirewall
| InventoryAction::Update
| InventoryAction::Uninstall => Ok(Some(candidate)),
InventoryAction::Install | InventoryAction::Cutover => Err(InventoryIssue::new(
"component_already_current",
"Компонент уже находится в текущей управляемой папке.",
)),
},
ComponentClassification::ManagedLegacy => match action {
InventoryAction::CheckBinary => Ok(Some(candidate)),
InventoryAction::Apply
| InventoryAction::Install
| InventoryAction::Start
| InventoryAction::Stop
| InventoryAction::ConfigureFirewall
| InventoryAction::Update
| InventoryAction::Uninstall
| InventoryAction::Cutover => Err(InventoryIssue::new(
"legacy_cutover_required",
"Старая установка требует отдельного доказанного cutover-потока.",
)),
},
ComponentClassification::Foreign => {
Err(candidate.issues.first().cloned().unwrap_or_else(|| {
InventoryIssue::new(
FOREIGN_COMPONENT,
"Найдена чужая установка; управление ею заблокировано.",
)
}))
}
ComponentClassification::Incomplete => {
Err(candidate.issues.first().cloned().unwrap_or_else(|| {
InventoryIssue::new(
COMPONENT_INCOMPLETE,
"Установка компонента неполна; опасные действия заблокированы.",
)
}))
}
ComponentClassification::Missing => {
if action == InventoryAction::Install {
Ok(None)
} else {
Err(InventoryIssue::new(
COMPONENT_MISSING,
"Управляемый компонент не найден.",
))
}
}
}
}
/// Produces read-only proof for the one supported automatic legacy cutover.
///
/// A `ManagedLegacy` candidate alone is discovery evidence, not mutation
/// authority. The caller must independently match the leased ten-file package
/// manifest and query the complete live SCM profile before calling this gate.
pub fn prove_legacy_cutover(
inventory: &ComponentInventory,
evidence: &LegacyCutoverEvidence,
) -> Result<LegacyCutoverProof, InventoryIssue> {
let manual = || {
Err(InventoryIssue::new(
MANUAL_MIGRATION_REQUIRED,
"Найдена старая установка, но ее identity недостаточна для автоматического переноса.",
))
};
if inventory.component_id != ComponentId::Proxyfier
|| !inventory.issues.is_empty()
|| inventory.candidates.len() != 1
{
return manual();
}
let Some(candidate) = inventory.selected_candidate() else {
return manual();
};
if candidate.component_id != ComponentId::Proxyfier
|| candidate.classification != ComponentClassification::ManagedLegacy
|| candidate.role != CandidateRole::Legacy
|| !candidate.issues.is_empty()
|| normalized_identity_path(&candidate.root)
!= normalized_identity_text(LEGACY_PROXIFYRE_AUTO_CUTOVER_ROOT)
|| !candidate
.binary_version
.as_deref()
.is_some_and(legacy_proxifyre_version_matches)
|| !evidence.proxifyre_manifest_matches
|| !is_sha256(&evidence.proxifyre_scm_snapshot_fingerprint)
|| evidence.additional_matching_service
|| !evidence
.proxifyre_scm_profile
.as_ref()
.is_some_and(LegacyProxifyreScmProfile::matches_frozen_2_2_1_profile)
{
return manual();
}
let expected_executable = candidate.root.join("ProxiFyre.exe");
if candidate.executable_path.as_deref().is_none_or(|path| {
normalized_identity_path(path) != normalized_identity_path(&expected_executable)
}) {
return manual();
}
let Some(service) = candidate.service.as_ref() else {
return manual();
};
if !service
.name
.eq_ignore_ascii_case(LEGACY_PROXIFYRE_PRIMARY_SERVICE)
|| !matches!(
service.status.trim().to_ascii_lowercase().as_str(),
"running" | "stopped"
)
|| service
.binary_version
.as_deref()
.is_none_or(|version| !legacy_proxifyre_version_matches(version))
|| service.executable_path.as_deref().is_none_or(|path| {
normalized_identity_path(path) != normalized_identity_path(&expected_executable)
})
|| service.path_name.as_deref().is_none_or(|path_name| {
!legacy_proxifyre_topshelf_path_matches(path_name, &expected_executable)
})
{
return manual();
}
let identity_fingerprint = json!({
"domain": "proxywarden-legacy-cutover-proof-v1",
"candidate": legacy_candidate_fingerprint(candidate, service),
"evidence": evidence,
});
Ok(LegacyCutoverProof {
identity_fingerprint: format!(
"{:x}",
Sha256::digest(identity_fingerprint.to_string().as_bytes())
),
})
}
/// Cross-platform pure matcher for the historical Topshelf service command.
/// It parses Windows quoting rules even when contract tests run on Linux.
pub fn legacy_proxifyre_topshelf_path_matches(path_name: &str, expected_executable: &Path) -> bool {
if normalized_identity_path(expected_executable)
!= normalized_identity_text(r"C:\Tools\ProxiFyre\ProxiFyre.exe")
{
return false;
}
let Some(arguments) = split_windows_command_line(path_name) else {
return false;
};
if arguments.len() != 5
|| normalized_identity_text(&arguments[0]) != normalized_identity_path(expected_executable)
{
return false;
}
let mut display_name = false;
let mut service_name = false;
for pair in arguments[1..].chunks_exact(2) {
match (pair[0].to_ascii_lowercase().as_str(), pair[1].as_str()) {
("-displayname", "ProxiFyre Service") if !display_name => display_name = true,
("-servicename", LEGACY_PROXIFYRE_PRIMARY_SERVICE) if !service_name => {
service_name = true;
}
_ => return false,
}
}
display_name && service_name
}
pub fn run_authorized_component_action<T, E>(
inventory: &ComponentInventory,
action: InventoryAction,
runner: impl FnOnce(Option<&ComponentCandidate>) -> Result<T, E>,
) -> Result<T, AuthorizedActionError<E>> {
let candidate =
authorize_component_action(inventory, action).map_err(AuthorizedActionError::Denied)?;
runner(candidate).map_err(AuthorizedActionError::Runner)
}
pub fn capture_legacy_component_identity(
inventory: &ComponentInventory,
) -> Result<LegacyComponentIdentity, InventoryIssue> {
if !inventory.issues.is_empty() {
return Err(legacy_identity_changed());
}
let candidate = inventory
.selected_candidate()
.filter(|candidate| candidate.classification == ComponentClassification::ManagedLegacy)
.ok_or_else(legacy_identity_changed)?;
let service = candidate
.service
.as_ref()
.filter(|service| {
!service.name.trim().is_empty()
&& service
.path_name
.as_deref()
.is_some_and(|path_name| !path_name.trim().is_empty())
&& service.executable_path.is_some()
&& service.path_matches_candidate
})
.ok_or_else(legacy_identity_changed)?;
if candidate.component_id != inventory.component_id
|| candidate.executable_path.is_none()
|| !candidate.issues.is_empty()
{
return Err(legacy_identity_changed());
}
Ok(LegacyComponentIdentity {
component_id: inventory.component_id.clone(),
fingerprint: legacy_candidate_fingerprint(candidate, service),
})
}
pub fn revalidate_legacy_component<'a>(
expected: &LegacyComponentIdentity,
inventory: &'a ComponentInventory,
action: InventoryAction,
) -> Result<&'a ComponentCandidate, InventoryIssue> {
if !matches!(action, InventoryAction::Start | InventoryAction::Stop)
|| inventory.component_id != expected.component_id
{
return Err(legacy_identity_changed());
}
let candidate = authorize_component_action(inventory, action)
.ok()
.flatten()
.filter(|candidate| candidate.classification == ComponentClassification::ManagedLegacy)
.ok_or_else(legacy_identity_changed)?;
let actual = capture_legacy_component_identity(inventory)?;
if actual != *expected {
return Err(legacy_identity_changed());
}
Ok(candidate)
}
pub fn run_revalidated_legacy_action<T, E>(
expected: &LegacyComponentIdentity,
inventory: &ComponentInventory,
action: InventoryAction,
runner: impl FnOnce(&ComponentCandidate) -> Result<T, E>,
) -> Result<T, AuthorizedActionError<E>> {
let candidate = revalidate_legacy_component(expected, inventory, action)
.map_err(AuthorizedActionError::Denied)?;
runner(candidate).map_err(AuthorizedActionError::Runner)
}
fn legacy_candidate_fingerprint(
candidate: &ComponentCandidate,
service: &ServiceEvidence,
) -> String {
let value = json!({
"component": component_identity_label(&candidate.component_id),
"classification": "managed-legacy",
"role": candidate_role_label(candidate.role),
"root": normalized_identity_path(&candidate.root),
"executable": candidate.executable_path.as_deref().map(normalized_identity_path),
"binaryIdentity": "known-package",
"binaryVersion": candidate.binary_version,
"marker": marker_identity_label(candidate.marker),
"service": {
"name": service.name.to_ascii_lowercase(),
"pathName": service.path_name.as_deref().map(normalized_identity_text),
"executable": service.executable_path.as_deref().map(normalized_identity_path),
"pathMatchesCandidate": service.path_matches_candidate,
"binaryVersion": service.binary_version,
},
});
format!("{:x}", Sha256::digest(value.to_string().as_bytes()))
}
/// Canonical redacted identity used by normal startup, the privileged plan,
/// and elevated next-start verification. Keeping this in the inventory owner
/// prevents subtly different hashes from authorizing cleanup.
pub fn component_inventory_fingerprint_for_cutover(inventory: &ComponentInventory) -> String {
let mut candidates = inventory
.candidates
.iter()
.map(inventory_candidate_fingerprint_value)
.collect::<Vec<_>>();
candidates.sort_by_key(Value::to_string);
let mut issues = inventory
.issues
.iter()
.map(|issue| issue.code.clone())
.collect::<Vec<_>>();
issues.sort();
let value = json!({
"component": component_identity_label(&inventory.component_id),
"selected": inventory.selected_candidate().map(inventory_candidate_fingerprint_value),
"candidates": candidates,
"issues": issues,
});
format!("{:x}", Sha256::digest(value.to_string().as_bytes()))
}
fn inventory_candidate_fingerprint_value(candidate: &ComponentCandidate) -> Value {
let mut issues = candidate
.issues
.iter()
.map(|issue| issue.code.clone())
.collect::<Vec<_>>();
issues.sort();
json!({
"component": component_identity_label(&candidate.component_id),
"classification": component_classification_label(candidate.classification),
"role": candidate_role_label(candidate.role),
"root": normalized_inventory_path(&candidate.root),
"executable": candidate.executable_path.as_deref().map(normalized_inventory_path),
"binaryVersion": candidate.binary_version,
"marker": marker_identity_label(candidate.marker),
"service": candidate.service.as_ref().map(|service| json!({
"name": service.name.to_ascii_lowercase(),
"status": service.status.to_ascii_lowercase(),
"pathName": service.path_name.as_deref().map(normalized_inventory_text),
"executable": service.executable_path.as_deref().map(normalized_inventory_path),
"pathMatches": service.path_matches_candidate,
"binaryVersion": service.binary_version,
})),
"issues": issues,
})
}
fn component_classification_label(classification: ComponentClassification) -> &'static str {
match classification {
ComponentClassification::ManagedCurrent => "managed-current",
ComponentClassification::ManagedLegacy => "managed-legacy",
ComponentClassification::Foreign => "foreign",
ComponentClassification::Incomplete => "incomplete",
ComponentClassification::Missing => "missing",
}
}
fn normalized_inventory_path(path: &Path) -> String {
normalized_inventory_text(&path.to_string_lossy())
}
fn normalized_inventory_text(value: &str) -> String {
value.trim().replace('/', "\\").to_ascii_lowercase()
}
fn legacy_identity_changed() -> InventoryIssue {
InventoryIssue::new(
LEGACY_IDENTITY_CHANGED,
"Старая управляемая установка изменилась после проверки; действие отменено.",
)
}
fn legacy_proxifyre_version_matches(version: &str) -> bool {
matches!(
version.trim(),
LEGACY_PROXIFYRE_AUTO_CUTOVER_VERSION | LEGACY_PROXIFYRE_FIXED_VERSION
)
}
fn is_sha256(value: &str) -> bool {
value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn normalized_identity_path(path: &Path) -> String {
normalized_identity_text(&path.to_string_lossy())
}
fn normalized_identity_text(value: &str) -> String {
value
.trim()
.replace('/', "\\")
.trim_end_matches('\\')
.to_ascii_lowercase()
}
fn split_windows_command_line(value: &str) -> Option<Vec<String>> {
if value.contains('\0') {
return None;
}
let characters: Vec<char> = value.chars().collect();
let mut index = 0;
let mut arguments = Vec::new();
while index < characters.len() {
while index < characters.len() && characters[index].is_whitespace() {
index += 1;
}
if index == characters.len() {
break;
}
let mut argument = String::new();
let mut quoted = false;
while index < characters.len() {
if characters[index] == '\\' {
let start = index;
while index < characters.len() && characters[index] == '\\' {
index += 1;
}
let count = index - start;
if index < characters.len() && characters[index] == '"' {
argument.extend(std::iter::repeat_n('\\', count / 2));
if count % 2 == 0 {
quoted = !quoted;
} else {
argument.push('"');
}
index += 1;
} else {
argument.extend(std::iter::repeat_n('\\', count));
}
continue;
}
match characters[index] {
'"' => quoted = !quoted,
character if character.is_whitespace() && !quoted => break,
character => argument.push(character),
}
index += 1;
}
if quoted || argument.is_empty() {
return None;
}
arguments.push(argument);
while index < characters.len() && characters[index].is_whitespace() {
index += 1;
}
}
(!arguments.is_empty()).then_some(arguments)
}
fn component_identity_label(component: &ComponentId) -> &'static str {
match component {
ComponentId::ControlApp => "control-app",
ComponentId::Proxyfier => "proxifyre",
ComponentId::Singbox => "sing-box",
}
}
fn candidate_role_label(role: CandidateRole) -> &'static str {
match role {
CandidateRole::Current => "current",
CandidateRole::Legacy => "legacy",
CandidateRole::ForeignByDefault => "foreign-by-default",
CandidateRole::Foreign => "foreign",
}
}
fn marker_identity_label(marker: MarkerEvidence) -> &'static str {
match marker {
MarkerEvidence::Valid => "valid",
MarkerEvidence::Missing => "missing",
MarkerEvidence::Invalid => "invalid",
MarkerEvidence::NotRequired => "not-required",
}
}
fn classify_candidate(probe: ComponentCandidateProbe) -> ComponentCandidate {
let mut issues = Vec::new();
let classification =
if !probe.root_exists && probe.executable_path.is_none() && probe.service.is_none() {
ComponentClassification::Missing
} else if probe.has_reparse_point {
issues.push(InventoryIssue::new(
OWNERSHIP_MISMATCH,
format!(
"Путь компонента содержит reparse point и не может считаться управляемым: {}",
probe.root.display()
),
));
ComponentClassification::Foreign
} else if probe.binary_identity == BinaryIdentityEvidence::Mismatch {
issues.push(InventoryIssue::new(
OWNERSHIP_MISMATCH,
"Binary не совпадает с известным пакетом ProxyWarden.",
));
ComponentClassification::Foreign
} else if probe
.service
.as_ref()
.is_some_and(|service| !service.path_matches_candidate)
{
issues.push(InventoryIssue::new(
OWNERSHIP_MISMATCH,
"Имя службы совпало, но ее PathName указывает на другой binary.",
));
ComponentClassification::Foreign
} else if probe.role == CandidateRole::Foreign {
let (code, message) = if probe.service.is_some() {
(
OWNERSHIP_MISMATCH,
"Служба с известным именем указывает в путь вне allowlist ProxyWarden.",
)
} else {
(
FOREIGN_COMPONENT,
"Путь не входит в allowlist управляемых установок ProxyWarden.",
)
};
issues.push(InventoryIssue::new(code, message));
ComponentClassification::Foreign
} else if !probe.root_exists || !probe.missing_files.is_empty() {
issues.push(InventoryIssue::new(
COMPONENT_INCOMPLETE,
missing_files_message(&probe.root, &probe.missing_files),
));
incomplete_classification(probe.role)
} else if probe.marker_required && probe.marker != MarkerEvidence::Valid {
let code = if probe.marker == MarkerEvidence::Invalid {
OWNERSHIP_MISMATCH
} else {
COMPONENT_INCOMPLETE
};
issues.push(InventoryIssue::new(
code,
"Marker установки не подтверждает владение ProxyWarden.",
));
if probe.marker == MarkerEvidence::Invalid {
ComponentClassification::Foreign
} else {
incomplete_classification(probe.role)
}
} else if probe.service_required && probe.service.is_none() {
issues.push(InventoryIssue::new(
COMPONENT_INCOMPLETE,
"Ожидаемая Windows-служба отсутствует.",
));
incomplete_classification(probe.role)
} else {
match probe.role {
CandidateRole::Current
if probe.marker == MarkerEvidence::Valid || probe.legacy_identity_complete =>
{
ComponentClassification::ManagedCurrent
}
CandidateRole::Legacy | CandidateRole::ForeignByDefault
if probe.legacy_identity_complete
&& probe.binary_identity == BinaryIdentityEvidence::KnownPackage =>
{
ComponentClassification::ManagedLegacy
}
CandidateRole::ForeignByDefault => {
issues.push(InventoryIssue::new(
FOREIGN_COMPONENT,
"Путь считается чужим без полной legacy identity ProxyWarden.",
));
ComponentClassification::Foreign
}
CandidateRole::Current | CandidateRole::Legacy => {
issues.push(InventoryIssue::new(
COMPONENT_INCOMPLETE,
"Недостаточно evidence для подтверждения владения компонентом.",
));
ComponentClassification::Incomplete
}
CandidateRole::Foreign => ComponentClassification::Foreign,
}
};
ComponentCandidate {
component_id: probe.component_id,
classification,
role: probe.role,
root: probe.root,
executable_path: probe.executable_path,
binary_version: probe.binary_version,
service: probe.service,
marker: probe.marker,
issues,
}
}
fn incomplete_classification(role: CandidateRole) -> ComponentClassification {
if role == CandidateRole::ForeignByDefault {
ComponentClassification::Foreign
} else {
ComponentClassification::Incomplete
}
}
fn missing_files_message(root: &Path, missing_files: &[PathBuf]) -> String {
if missing_files.is_empty() {
return format!("Папка компонента отсутствует: {}", root.display());
}
let names = missing_files
.iter()
.filter_map(|path| path.file_name().and_then(|name| name.to_str()))
.collect::<Vec<_>>()
.join(", ");
format!("Установка неполна; отсутствуют: {names}")
}
File diff suppressed because it is too large Load Diff
+28 -31
View File
@@ -1,49 +1,32 @@
//! Live component status resolution and read-only route/profile presentation.
use crate::command_dto::{CommandError, ResolvedAppDto};
use crate::command_dto::ResolvedAppDto;
use crate::component_detection::{
detect_proxyfier_install, detect_singbox_install, proxyfier_component_from_detection,
singbox_component_from_detection, DetectedProxyfier, DetectedSingBox,
inventory_proxyfier, inventory_singbox, proxyfier_component_from_detection,
proxyfier_component_from_inventory, singbox_component_from_detection,
singbox_component_from_inventory, DetectedProxyfier, DetectedSingBox,
};
use crate::component_inventory::ComponentInventory;
use crate::models::{
ComponentId, ComponentState, ComponentStatus, ProfileItem, ProfileItemType, Target,
};
use crate::storage::JsonStorage;
pub(crate) fn components_or_defaults(
storage: &JsonStorage,
) -> Result<Vec<ComponentStatus>, CommandError> {
components_or_defaults_with_detection(
storage,
detect_proxyfier_install(),
detect_singbox_install(),
)
pub(crate) fn live_components() -> Vec<ComponentStatus> {
resolve_component_statuses_with_inventories(&inventory_proxyfier(), &inventory_singbox())
}
pub(crate) fn components_or_defaults_with_detection(
storage: &JsonStorage,
pub(crate) fn components_with_detection(
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Result<Vec<ComponentStatus>, CommandError> {
let components = storage.read_components().map_err(storage_error)?;
Ok(resolve_component_statuses(
components,
detected_proxyfier,
detected_singbox,
))
) -> Vec<ComponentStatus> {
resolve_component_statuses(detected_proxyfier, detected_singbox)
}
pub fn resolve_component_statuses(
stored_components: Vec<ComponentStatus>,
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Vec<ComponentStatus> {
let mut components = default_components();
for component in stored_components {
upsert_component(&mut components, component);
}
upsert_component(
&mut components,
proxyfier_component_from_detection(detected_proxyfier.as_ref()),
@@ -56,6 +39,24 @@ pub fn resolve_component_statuses(
components
}
pub fn resolve_component_statuses_with_inventories(
proxyfier_inventory: &ComponentInventory,
singbox_inventory: &ComponentInventory,
) -> Vec<ComponentStatus> {
let mut components = default_components();
upsert_component(
&mut components,
proxyfier_component_from_inventory(proxyfier_inventory),
);
upsert_component(
&mut components,
singbox_component_from_inventory(singbox_inventory),
);
components
}
fn default_components() -> Vec<ComponentStatus> {
vec![
ComponentStatus {
@@ -150,7 +151,3 @@ pub(crate) fn resolved_app(item: &ProfileItem, warnings: &mut Vec<String>) -> Re
notes,
}
}
fn storage_error(error: std::io::Error) -> CommandError {
CommandError::new("storage_error", error.to_string())
}
+295
View File
@@ -0,0 +1,295 @@
//! One process-independent configuration lock and a fixed, recoverable commit.
//! Only ProgramData source/generated files are included; this is never privileged authority.
use crate::{safe_fs, storage::JsonStorage};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{self, File, OpenOptions},
io,
path::{Path, PathBuf},
};
pub struct RootGuard {
_file: File,
}
pub fn acquire_root(storage: &JsonStorage) -> io::Result<RootGuard> {
let dir = &storage.paths().migrations_dir;
safe_fs::ensure_no_reparse_ancestors(dir)?;
fs::create_dir_all(dir)?;
safe_fs::protect_path_for_owner_admin_system(dir)?;
let path = dir.join("storage-migration.lock");
safe_fs::ensure_no_reparse_ancestors(&path)?;
let mut options = OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
options.share_mode(0);
}
let file = options.open(&path)?;
safe_fs::protect_path_for_owner_admin_system(&path)?;
#[cfg(not(windows))]
file.try_lock().map_err(io::Error::other)?;
Ok(RootGuard { _file: file })
}
pub fn read_guard(storage: &JsonStorage) -> io::Result<RootGuard> {
let guard = acquire_root(storage)?;
if migration_active(storage).try_exists()? {
return Err(io::Error::other(
"storage recovery required before reading configuration",
));
}
recover_locked(storage)?;
Ok(guard)
}
fn migration_active(storage: &JsonStorage) -> PathBuf {
storage
.paths()
.migrations_dir
.join("active-storage-migration.json")
}
fn journal_path(storage: &JsonStorage) -> PathBuf {
storage
.paths()
.migrations_dir
.join("configuration-commit.json")
}
fn snapshot_path(storage: &JsonStorage, index: usize) -> PathBuf {
storage
.paths()
.migrations_dir
.join(format!("configuration-before-{index}.json"))
}
fn revision_path(storage: &JsonStorage) -> PathBuf {
storage
.paths()
.state_dir
.join("configuration-revision.json")
}
fn tracked_paths(storage: &JsonStorage) -> Vec<PathBuf> {
let paths = storage.paths();
[
paths.profiles_file.clone(),
paths.targets_file.clone(),
paths.local_singbox_file.clone(),
paths.singbox_subscription_cache_file.clone(),
paths.generated_dir.join("proxifyre-app-config.json"),
paths.generated_dir.join("sing-box-config.json"),
revision_path(storage),
crate::route_state::prepared_path(storage),
]
.into_iter()
.flat_map(|path| [path.clone(), safe_fs::backup_path(&path)])
.collect()
}
fn optional_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
safe_fs::ensure_no_reparse_ancestors(path)?;
match fs::read(path) {
Ok(bytes) => Ok(Some(bytes)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn digest(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// Read only while holding this module's root guard. Content protects against uncoordinated old writers too.
pub fn revision_locked(storage: &JsonStorage) -> io::Result<String> {
let mut hash = Sha256::new();
for path in tracked_paths(storage).into_iter().step_by(2) {
match optional_bytes(&path)? {
Some(bytes) => {
hash.update([1]);
hash.update((bytes.len() as u64).to_le_bytes());
hash.update(bytes);
}
None => hash.update([0]),
}
}
Ok(format!("{:x}", hash.finalize()))
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Intent {
version: u8,
committed: bool,
before: Vec<Option<String>>,
}
/// Caller must hold the common root lock; migration calls this before inspecting source.
pub fn recover_locked(storage: &JsonStorage) -> io::Result<()> {
let Some(bytes) = optional_bytes(&journal_path(storage))? else {
return Ok(());
};
if migration_active(storage).try_exists()? {
return Err(io::Error::other(
"conflicting storage intents require recovery",
));
}
let intent: Intent = serde_json::from_slice(&bytes)
.map_err(|_| io::Error::other("invalid configuration intent"))?;
let paths = tracked_paths(storage);
if intent.version != 1 || intent.before.len() != paths.len() {
return Err(io::Error::other("unsupported configuration intent"));
}
if !intent.committed {
// Verify every snapshot before the first restoration, including absent destinations.
let mut snapshots = Vec::new();
for (index, expected) in intent.before.iter().enumerate() {
safe_fs::ensure_no_reparse_ancestors(&paths[index])?;
snapshots.push(match expected {
Some(hash) => {
let bytes = optional_bytes(&snapshot_path(storage, index))?
.ok_or_else(|| io::Error::other("missing configuration snapshot"))?;
if digest(&bytes) != *hash {
return Err(io::Error::other("damaged configuration snapshot"));
}
Some(bytes)
}
None => None,
});
}
for (path, bytes) in paths.iter().zip(snapshots) {
match bytes {
Some(bytes) => safe_fs::write_restricted_atomic(path, &bytes)?,
None => remove_optional(path)?,
}
}
}
cleanup(storage, paths.len())
}
fn remove_optional(path: &Path) -> io::Result<()> {
safe_fs::ensure_no_reparse_ancestors(path)?;
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn cleanup(storage: &JsonStorage, count: usize) -> io::Result<()> {
// Mark rollback complete before deleting any snapshot, so interrupted cleanup is retryable.
let marker = Intent {
version: 1,
committed: true,
before: vec![None; count],
};
safe_fs::write_restricted_atomic(&journal_path(storage), &serde_json::to_vec(&marker)?)?;
for index in 0..count {
remove_optional(&safe_fs::backup_path(&snapshot_path(storage, index)))?;
remove_optional(&snapshot_path(storage, index))?;
}
remove_optional(&safe_fs::backup_path(&journal_path(storage)))?;
remove_optional(&journal_path(storage))
}
pub struct ConfigurationTransaction<'a> {
storage: &'a JsonStorage,
guard: Option<RootGuard>,
intent: Intent,
committed: bool,
}
impl<'a> ConfigurationTransaction<'a> {
pub fn begin(storage: &'a JsonStorage, expected: Option<&str>) -> io::Result<Self> {
let guard = read_guard(storage)?;
if let Some(expected) = expected {
if revision_locked(storage)? != expected {
return Err(io::Error::other(
"configuration changed; retry using current settings",
));
}
}
let mut before = Vec::new();
for (index, path) in tracked_paths(storage).iter().enumerate() {
before.push(match optional_bytes(path)? {
Some(bytes) => {
safe_fs::write_restricted_atomic(&snapshot_path(storage, index), &bytes)?;
Some(digest(&bytes))
}
None => None,
});
}
let intent = Intent {
version: 1,
committed: false,
before,
};
safe_fs::write_restricted_atomic(&journal_path(storage), &serde_json::to_vec(&intent)?)?;
Ok(Self {
storage,
guard: Some(guard),
intent,
committed: false,
})
}
pub fn commit(self) -> io::Result<()> {
self.commit_with_revision().map(|_| ())
}
pub fn commit_with_revision(mut self) -> io::Result<String> {
// A fresh nonce records intent even if a later edit returns source to identical bytes.
let prepared = safe_fs::write_restricted_atomic(
&revision_path(self.storage),
&serde_json::to_vec(&uuid::Uuid::new_v4().to_string())?,
)
.and_then(|()| revision_locked(self.storage));
let revision = match prepared {
Ok(revision) => revision,
Err(error) => {
self.committed = true;
return match recover_locked(self.storage) {
Ok(()) => Err(error),
Err(_) => Err(io::Error::other(
"configuration_recovery_required: восстановление сохранения не завершено",
)),
};
}
};
self.intent.committed = true;
let marker = serde_json::to_vec(&self.intent)?;
if let Err(error) = safe_fs::write_restricted_atomic(&journal_path(self.storage), &marker) {
// The atomic writer may fail its final ACL step after promotion.
// Read back the exact marker under the same lock before deciding the outcome.
match optional_bytes(&journal_path(self.storage)) {
Ok(Some(bytes)) if bytes == marker => {}
Ok(Some(_)) => {
self.committed = true;
return match recover_locked(self.storage) {
Ok(()) => Err(error),
Err(_) => Err(io::Error::other("configuration_recovery_required: восстановление сохранения не завершено")),
};
}
_ => {
self.committed = true;
return Err(io::Error::other("configuration_outcome_unknown: итог сохранения не подтверждён; обновите состояние перед повтором"));
}
}
}
self.committed = true;
let _ = cleanup(self.storage, self.intent.before.len());
self.guard.take();
Ok(revision)
}
pub fn abort(mut self) -> io::Result<()> {
let result = recover_locked(self.storage);
// Do not silently retry and hide a failed explicit recovery in Drop.
self.committed = true;
result
}
}
impl Drop for ConfigurationTransaction<'_> {
fn drop(&mut self) {
if !self.committed {
let _ = recover_locked(self.storage);
}
// Failed recovery leaves the durable intent for the next guarded read, never fresh defaults.
}
}
+247 -245
View File
@@ -1,33 +1,35 @@
//! Persisted profiles/targets, startup snapshot, ProxiFyre bootstrap import, and preview use cases.
use crate::configuration_transaction::{read_guard, ConfigurationTransaction};
// Persisted profiles/targets, startup preparation, and preview use cases.
use crate::adapters::proxifyre::{ProxiFyreConfig, ProxiFyreProxy};
use crate::adapters::proxifyre::{ProxiFyreAdapter, PROXIFYRE_OUTPUT_FILE};
use crate::adapters::proxy_router::{ProxyRouterAdapter, ProxyRouterRequest};
use crate::admin::admin_status;
use crate::command_dto::*;
use crate::component_detection::{
default_proxifyre_install_dir, default_singbox_install_dir, detect_proxyfier_install,
detect_singbox_install,
default_proxifyre_install_dir, default_singbox_install_dir, detected_proxyfier_from_inventory,
detected_singbox_from_inventory, inventory_proxyfier, inventory_singbox,
};
use crate::component_inventory::ComponentClassification;
use crate::component_status::{
components_or_defaults, resolve_component_statuses, resolved_app, route_line,
live_components, resolve_component_statuses_with_inventories, resolved_app, route_line,
};
use crate::models::{
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
use crate::migration::{
prepare_storage, reconcile_component_layout, record_component_cutover_startup_evidence,
recover_incomplete_migration, with_component_layout,
};
use crate::proxifyre_runtime::build_proxifyre_setup_status_with_detection;
use crate::safe_fs;
use crate::singbox_service::build_singbox_setup_status_with_install_root;
use crate::singbox_subscription::read_singbox_status_with_detection;
use crate::storage::JsonStorage;
use crate::validation::{normalize_profile, normalize_target, ValidationError};
use std::fs;
use std::path::Path;
const MAIN_PROFILE_ID: &str = "main-profile";
const MAIN_TARGET_ID: &str = "main-proxy";
pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandError> {
let _guard = read_guard(storage).map_err(storage_error)?;
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
let components = components_or_defaults(storage)?;
let components = live_components();
let activity = storage.read_activity().map_err(storage_error)?;
let active_profile_count = profiles.iter().filter(|profile| profile.enabled).count();
let routed_app_count = profiles
@@ -62,6 +64,7 @@ pub fn build_status(storage: &JsonStorage) -> Result<StatusResponse, CommandErro
}
pub fn read_profiles(storage: &JsonStorage) -> Result<Vec<ProfileDto>, CommandError> {
let _guard = read_guard(storage).map_err(storage_error)?;
storage
.read_profiles()
.map_err(storage_error)
@@ -72,6 +75,7 @@ pub fn save_profile_to_storage(
storage: &JsonStorage,
input: ProfileInputDto,
) -> Result<ProfileDto, CommandError> {
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
let profile = normalize_profile(input.into()).map_err(validation_error)?;
let mut profiles = storage.read_profiles().map_err(storage_error)?;
@@ -84,10 +88,12 @@ pub fn save_profile_to_storage(
}
storage.write_profiles(&profiles).map_err(storage_error)?;
transaction.commit().map_err(storage_error)?;
Ok(ProfileDto::from(&profile))
}
pub fn read_targets(storage: &JsonStorage) -> Result<Vec<TargetDto>, CommandError> {
let _guard = read_guard(storage).map_err(storage_error)?;
storage
.read_targets()
.map_err(storage_error)
@@ -98,6 +104,7 @@ pub fn save_target_to_storage(
storage: &JsonStorage,
input: TargetInputDto,
) -> Result<TargetDto, CommandError> {
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
let target = normalize_target(input.into()).map_err(validation_error)?;
let mut targets = storage.read_targets().map_err(storage_error)?;
@@ -107,46 +114,155 @@ pub fn save_target_to_storage(
}
storage.write_targets(&targets).map_err(storage_error)?;
transaction.commit().map_err(storage_error)?;
Ok(TargetDto::from(&target))
}
pub fn read_components(storage: &JsonStorage) -> Result<Vec<ComponentStatusDto>, CommandError> {
components_or_defaults(storage).map(|components| {
components
.iter()
.map(ComponentStatusDto::from)
.collect::<Vec<_>>()
})
pub fn read_live_components() -> Vec<ComponentStatusDto> {
live_components()
.iter()
.map(ComponentStatusDto::from)
.collect()
}
/// Rebuilds only an existing, untrusted derived config from authoritative
/// profiles/targets. A missing config still requires an explicit Apply action.
pub fn ensure_proxifyre_generated_config_ready(storage: &JsonStorage) -> Result<(), CommandError> {
let guard = read_guard(storage).map_err(storage_error)?;
let path = storage.paths().generated_dir.join(PROXIFYRE_OUTPUT_FILE);
if !path.try_exists().map_err(storage_error)? {
return Err(CommandError::new(
"generated_config_missing",
"Сначала нажмите «Применить», чтобы создать конфигурацию ProxiFyre.",
));
}
let profiles = storage.read_profiles().map_err(storage_error)?;
if !profiles
.iter()
.any(|profile| profile.enabled && !profile.items.is_empty())
{
return Err(CommandError::new(
"route_has_no_apps",
"Нет включённых правил. Добавьте приложения и примените конфигурацию перед запуском ProxiFyre.",
));
}
if safe_fs::open_restricted_file_read_lease(&path).is_ok() {
return Ok(());
}
let targets = storage.read_targets().map_err(storage_error)?;
let components = live_components();
let generated = ProxiFyreAdapter::default()
.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components))
.map_err(|_| {
CommandError::new(
"generated_config_rebuild_failed",
"Старую конфигурацию ProxiFyre нельзя использовать. Нажмите «Применить», чтобы пересоздать её.",
)
})?;
let revision =
crate::configuration_transaction::revision_locked(storage).map_err(storage_error)?;
drop(guard);
let transaction =
ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?;
remove_untrusted_generated_file(&path, true)?;
remove_untrusted_generated_file(&safe_fs::backup_path(&path), false)?;
safe_fs::write_restricted_atomic(&path, generated.contents.as_bytes())
.map_err(|_| generated_config_rebuild_error())?;
transaction.commit().map_err(storage_error)
}
fn remove_untrusted_generated_file(
path: &std::path::Path,
required: bool,
) -> Result<(), CommandError> {
safe_fs::ensure_no_reparse_ancestors(path).map_err(|_| generated_config_rebuild_error())?;
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_file() => {
fs::remove_file(path).map_err(|_| generated_config_rebuild_error())
}
Ok(_) => Err(generated_config_rebuild_error()),
Err(error) if !required && error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(_) => Err(generated_config_rebuild_error()),
}
}
fn generated_config_rebuild_error() -> CommandError {
CommandError::new(
"generated_config_rebuild_failed",
"Не удалось безопасно пересоздать старую конфигурацию ProxiFyre. Нажмите «Применить» и повторите запуск.",
)
}
pub fn read_startup_snapshot(
storage: &JsonStorage,
startup_session_id: &str,
) -> Result<StartupSnapshotResponse, CommandError> {
// Resolve an interrupted storage transaction before any normal read or
// component-dependent startup work.
recover_incomplete_migration(storage)?;
// Both detectors query Windows independently. Run them together so the
// startup snapshot is bounded by the slower check instead of their sum.
let proxyfier_detection = std::thread::spawn(detect_proxyfier_install);
let detected_singbox = detect_singbox_install();
let detected_proxyfier = proxyfier_detection.join().ok().flatten();
let saved_state = read_saved_state_with_proxifyre_config(
let proxyfier_inventory_task = std::thread::spawn(inventory_proxyfier);
let singbox_inventory = inventory_singbox();
let proxyfier_inventory = proxyfier_inventory_task.join().map_err(|_| {
CommandError::new(
"component_inventory_failed",
"Не удалось проверить установку ProxiFyre.",
)
})?;
let detected_proxyfier = detected_proxyfier_from_inventory(&proxyfier_inventory);
let detected_singbox = detected_singbox_from_inventory(&singbox_inventory);
let mut legacy_candidates = vec![storage
.paths()
.generated_dir
.join("proxifyre-app-config.json")];
legacy_candidates.extend(
proxyfier_inventory
.candidates
.iter()
.filter(|candidate| {
matches!(
candidate.classification,
ComponentClassification::ManagedCurrent
| ComponentClassification::ManagedLegacy
)
})
.map(|candidate| candidate.root.join("app-config.json")),
);
let migration_status = prepare_storage(storage, &legacy_candidates)?;
if migration_status.blocking {
return Err(CommandError::new(
migration_status
.notice_code
.clone()
.unwrap_or_else(|| "storage_migration_blocked".to_string()),
migration_status.message,
));
}
let component_layout_version =
reconcile_component_layout(storage, &proxyfier_inventory, &singbox_inventory)?;
// This is an untrusted UX carrier. A failed write must not block normal
// startup; cleanup remains unavailable until an exact later observation.
let _ = record_component_cutover_startup_evidence(
storage,
detected_proxyfier
.as_ref()
.and_then(|detected| detected.config_path.as_deref()),
)?;
let stored_components = storage.read_components().map_err(storage_error)?;
let components = resolve_component_statuses(
stored_components,
detected_proxyfier.clone(),
detected_singbox.clone(),
)
.iter()
.map(ComponentStatusDto::from)
.collect();
startup_session_id,
&proxyfier_inventory,
);
let migration_status = with_component_layout(migration_status, component_layout_version);
let components =
resolve_component_statuses_with_inventories(&proxyfier_inventory, &singbox_inventory)
.iter()
.map(ComponentStatusDto::from)
.collect();
let proxifyre_setup_status = build_proxifyre_setup_status_with_detection(
detected_proxyfier.as_ref(),
&default_proxifyre_install_dir(),
);
let singbox_status = read_singbox_status_with_detection(storage, detected_singbox.as_ref())?;
let saved_state = singbox_status.saved_state.clone();
let singbox_setup_status = build_singbox_setup_status_with_install_root(
detected_singbox.as_ref(),
&default_singbox_install_dir(),
@@ -154,6 +270,7 @@ pub fn read_startup_snapshot(
Ok(StartupSnapshotResponse {
admin_status: admin_status(),
migration_status,
saved_state,
components,
proxifyre_setup_status,
@@ -170,29 +287,20 @@ pub fn read_activity(storage: &JsonStorage) -> Result<Vec<ActivityEntryDto>, Com
}
pub fn read_saved_state(storage: &JsonStorage) -> Result<SavedStateResponse, CommandError> {
let detected_config_path = detect_proxyfier_install().and_then(|detected| detected.config_path);
read_saved_state_with_proxifyre_config(storage, detected_config_path.as_deref())
let _guard = read_guard(storage).map_err(storage_error)?;
read_saved_state_locked(storage)
}
pub fn read_saved_state_with_proxifyre_config(
pub(crate) fn read_saved_state_locked(
storage: &JsonStorage,
proxifyre_config_path: Option<&Path>,
) -> Result<SavedStateResponse, CommandError> {
let mut profiles = storage.read_profiles().map_err(storage_error)?;
let mut targets = storage.read_targets().map_err(storage_error)?;
if should_bootstrap_profiles(&profiles) {
if let Some(imported) =
proxifyre_config_path.and_then(import_saved_state_from_proxifyre_config)
{
profiles = imported.profiles;
upsert_targets(&mut targets, imported.targets);
storage.write_targets(&targets).map_err(storage_error)?;
storage.write_profiles(&profiles).map_err(storage_error)?;
}
}
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
Ok(SavedStateResponse {
artifacts: crate::route_state::read_status_locked(storage).map_err(storage_error)?,
revision: crate::configuration_transaction::revision_locked(storage)
.map_err(storage_error)?,
profiles: profiles.iter().map(ProfileDto::from).collect(),
targets: targets.iter().map(TargetDto::from).collect(),
generated_config_path: storage
@@ -204,198 +312,6 @@ pub fn read_saved_state_with_proxifyre_config(
})
}
struct ImportedSavedState {
profiles: Vec<Profile>,
targets: Vec<Target>,
}
fn should_bootstrap_profiles(profiles: &[Profile]) -> bool {
!profiles
.iter()
.any(|profile| profile.enabled && !profile.items.is_empty())
}
fn import_saved_state_from_proxifyre_config(path: &Path) -> Option<ImportedSavedState> {
let contents = fs::read_to_string(path).ok()?;
let config: ProxiFyreConfig = serde_json::from_str(&contents).ok()?;
let proxy_entries = config
.proxies
.iter()
.filter_map(import_proxy_entry)
.collect::<Vec<_>>();
if proxy_entries.is_empty() {
return None;
}
let single_entry = proxy_entries.len() == 1;
let mut profiles = Vec::with_capacity(proxy_entries.len());
let mut targets = Vec::with_capacity(proxy_entries.len());
for (index, entry) in proxy_entries.into_iter().enumerate() {
let ordinal = index + 1;
let target_id = if single_entry {
MAIN_TARGET_ID.to_string()
} else {
format!("proxifyre-import-target-{ordinal}")
};
let profile_id = if single_entry {
MAIN_PROFILE_ID.to_string()
} else {
format!("proxifyre-import-profile-{ordinal}")
};
let profile_name = if single_entry {
"Приложения через прокси".to_string()
} else {
format!("Импорт ProxiFyre {ordinal}")
};
targets.push(Target {
id: target_id.clone(),
name: if single_entry {
"Основной прокси".to_string()
} else {
format!("Прокси ProxiFyre {ordinal}")
},
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: entry.host,
port: entry.port,
requires_component: None,
});
profiles.push(Profile {
id: profile_id,
name: profile_name,
enabled: true,
target_id,
protocols: entry.protocols,
items: entry.items,
});
}
Some(ImportedSavedState { profiles, targets })
}
struct ImportedProxyEntry {
items: Vec<ProfileItem>,
protocols: Vec<Protocol>,
host: String,
port: u16,
}
fn import_proxy_entry(proxy: &ProxiFyreProxy) -> Option<ImportedProxyEntry> {
let items = proxy
.app_names
.iter()
.filter_map(|name| imported_profile_item(name))
.collect::<Vec<_>>();
if items.is_empty() {
return None;
}
let (host, port) = parse_socks5_endpoint(&proxy.socks5_proxy_endpoint)?;
Some(ImportedProxyEntry {
items,
protocols: imported_protocols(&proxy.supported_protocols),
host,
port,
})
}
fn imported_profile_item(raw_value: &str) -> Option<ProfileItem> {
let value = raw_value.trim().trim_matches('"');
if value.is_empty() {
return None;
}
let looks_like_path = value.contains('\\') || value.contains('/');
let item_type = if looks_like_path && value.to_ascii_lowercase().ends_with(".exe") {
ProfileItemType::Exe
} else if looks_like_path {
ProfileItemType::Folder
} else {
ProfileItemType::Process
};
let value = match item_type {
ProfileItemType::Process => {
let base = value.rsplit(['\\', '/']).next().unwrap_or(value);
if base.to_ascii_lowercase().ends_with(".exe") {
base[..base.len() - 4].to_string()
} else {
base.to_string()
}
}
ProfileItemType::Folder | ProfileItemType::Exe => value.to_string(),
};
if value.is_empty() {
return None;
}
Some(ProfileItem {
recursive: matches!(item_type, ProfileItemType::Folder),
item_type,
value,
})
}
fn imported_protocols(values: &[String]) -> Vec<Protocol> {
let mut protocols = Vec::new();
for value in values {
let protocol = match value.trim().to_ascii_uppercase().as_str() {
"TCP" => Protocol::Tcp,
"UDP" => Protocol::Udp,
_ => continue,
};
if !protocols.contains(&protocol) {
protocols.push(protocol);
}
}
if protocols.is_empty() {
vec![Protocol::Tcp, Protocol::Udp]
} else {
protocols
}
}
fn parse_socks5_endpoint(endpoint: &str) -> Option<(String, u16)> {
let endpoint = endpoint.trim();
let endpoint = if endpoint
.get(.."socks5://".len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("socks5://"))
{
&endpoint["socks5://".len()..]
} else {
endpoint
};
if endpoint.is_empty() {
return None;
}
if let Some(rest) = endpoint.strip_prefix('[') {
let (host, rest) = rest.split_once(']')?;
let port = rest.strip_prefix(':')?.parse::<u16>().ok()?;
let host = host.trim();
return (!host.is_empty()).then(|| (host.to_string(), port));
}
let (host, port) = endpoint.rsplit_once(':')?;
let host = host.trim();
let port = port.trim().parse::<u16>().ok()?;
(!host.is_empty()).then(|| (host.to_string(), port))
}
fn upsert_targets(targets: &mut Vec<Target>, imported_targets: Vec<Target>) {
for target in imported_targets {
match targets.iter().position(|existing| existing.id == target.id) {
Some(index) => targets[index] = target,
None => targets.push(target),
}
}
}
pub fn resolve_preview(
input: ProfileInputDto,
) -> Result<ResolveProfilePreviewResponse, CommandError> {
@@ -431,3 +347,89 @@ fn validation_error(errors: Vec<ValidationError>) -> CommandError {
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{
Profile, ProfileItem, ProfileItemType, Protocol, ProxyProtocol, Target, TargetKind,
};
use std::path::Path;
#[test]
fn rebuilds_existing_untrusted_generated_config_from_source_of_truth() {
let root = test_root("rebuild-generated");
let storage = JsonStorage::new(&root);
storage
.write_profiles(&[test_profile()])
.expect("write profiles");
storage
.write_targets(&[test_target()])
.expect("write targets");
let generated = storage.paths().generated_dir.join(PROXIFYRE_OUTPUT_FILE);
fs::create_dir_all(generated.parent().expect("generated parent"))
.expect("create generated parent");
fs::write(&generated, b"untrusted legacy bytes").expect("write weak legacy config");
fs::write(safe_fs::backup_path(&generated), b"untrusted backup")
.expect("write weak legacy backup");
ensure_proxifyre_generated_config_ready(&storage).expect("rebuild generated config");
let contents = fs::read_to_string(&generated).expect("read rebuilt config");
assert!(contents.contains("Discord.exe"));
assert!(contents.contains("127.0.0.1:1080"));
assert!(!contents.contains("untrusted legacy bytes"));
assert!(!safe_fs::backup_path(&generated).exists());
#[cfg(windows)]
safe_fs::verify_path_protected_for_owner_admin_system(&generated)
.expect("rebuilt config keeps the restricted ACL");
cleanup(&root);
}
#[test]
fn missing_generated_config_still_requires_explicit_apply() {
let root = test_root("missing-generated");
let storage = JsonStorage::new(&root);
let error = ensure_proxifyre_generated_config_ready(&storage)
.expect_err("missing config must not be created implicitly");
assert_eq!(error.code, "generated_config_missing");
cleanup(&root);
}
fn test_profile() -> Profile {
Profile {
id: "test".to_string(),
name: "Test".to_string(),
enabled: true,
target_id: "external".to_string(),
protocols: vec![Protocol::Tcp, Protocol::Udp],
items: vec![ProfileItem {
item_type: ProfileItemType::Process,
value: "Discord.exe".to_string(),
recursive: false,
}],
}
}
fn test_target() -> Target {
Target {
id: "external".to_string(),
name: "External".to_string(),
kind: TargetKind::External,
protocol: ProxyProtocol::Socks5,
host: "127.0.0.1".to_string(),
port: 1080,
requires_component: None,
}
}
fn test_root(label: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("proxywarden-{label}-{}", uuid::Uuid::new_v4()))
}
fn cleanup(root: &Path) {
let _ = fs::remove_dir_all(root);
}
}
-19
View File
@@ -1,19 +0,0 @@
use std::env;
use std::path::{Path, PathBuf};
pub fn temp_script_path(prefix: &str) -> PathBuf {
env::temp_dir().join(unique_file_name(prefix, "ps1"))
}
pub fn artifact_path(artifact_dir: &Path, prefix: &str, extension: &str) -> PathBuf {
artifact_dir.join(unique_file_name(prefix, extension))
}
fn unique_file_name(prefix: &str, extension: &str) -> String {
let extension = extension.trim_start_matches('.');
format!(
"{prefix}-{}.{}",
uuid::Uuid::new_v4().hyphenated(),
extension
)
}
-184
View File
@@ -1,184 +0,0 @@
use crate::models::ComponentId;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum HelperAction {
#[serde(rename = "install-control-app")]
InstallControlApp,
#[serde(rename = "install-proxyfier")]
InstallProxyfier,
#[serde(rename = "install-singbox")]
InstallSingbox,
#[serde(rename = "proxyfier.apply")]
ProxyfierApply,
#[serde(rename = "service.status")]
ServiceStatus,
#[serde(rename = "service.start")]
ServiceStart,
#[serde(rename = "service.stop")]
ServiceStop,
#[serde(rename = "service.restart")]
ServiceRestart,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperRequest {
pub action: HelperAction,
#[serde(skip_serializing_if = "Option::is_none")]
pub component: Option<ComponentId>,
#[serde(default)]
pub payload: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperResponse {
pub success: bool,
pub action: HelperAction,
pub changed: bool,
pub message: String,
#[serde(default)]
pub details: Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandSpec {
pub program: PathBuf,
pub args: Vec<String>,
pub stdin: String,
pub requires_elevation: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HelperCommandOutput {
pub status_code: i32,
pub stdout: String,
pub stderr: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HelperError {
pub code: String,
pub message: String,
}
impl HelperError {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
}
pub trait HelperCommandRunner {
fn run(&self, spec: &HelperCommandSpec) -> Result<HelperCommandOutput, HelperError>;
}
#[derive(Debug, Clone)]
pub struct StructuredHelper<R> {
helper_program: PathBuf,
runner: R,
}
impl<R> StructuredHelper<R>
where
R: HelperCommandRunner,
{
pub fn new(helper_program: impl Into<PathBuf>, runner: R) -> Self {
Self {
helper_program: helper_program.into(),
runner,
}
}
pub fn runner(&self) -> &R {
&self.runner
}
pub fn execute(&self, request: &HelperRequest) -> Result<HelperResponse, HelperError> {
let stdin = serde_json::to_string(request)
.map_err(|error| HelperError::new("helper_request_encode", error.to_string()))?;
let spec = HelperCommandSpec {
program: self.helper_program.clone(),
args: vec!["--json".to_string()],
stdin,
requires_elevation: helper_action_requires_elevation(&request.action),
};
let output = self.runner.run(&spec)?;
if output.status_code != 0 {
return Err(HelperError::new(
"helper_exit",
format!(
"Помощник завершился с кодом {}: {}",
output.status_code, output.stderr
),
));
}
parse_helper_response(&output.stdout)
}
}
pub fn parse_helper_response(stdout: &str) -> Result<HelperResponse, HelperError> {
serde_json::from_str(stdout).map_err(|error| {
HelperError::new(
"helper_response_decode",
format!("Помощник вернул не JSON или некорректный JSON: {error}"),
)
})
}
pub fn install_request(component: ComponentId) -> HelperRequest {
let action = match component {
ComponentId::ControlApp => HelperAction::InstallControlApp,
ComponentId::Proxyfier => HelperAction::InstallProxyfier,
ComponentId::Singbox => HelperAction::InstallSingbox,
};
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn service_request(component: ComponentId, action: HelperAction) -> HelperRequest {
HelperRequest {
action,
component: Some(component),
payload: json!({}),
}
}
pub fn proxifyre_apply_request(
config_path: impl AsRef<Path>,
service_name: impl Into<String>,
) -> HelperRequest {
HelperRequest {
action: HelperAction::ProxyfierApply,
component: Some(ComponentId::Proxyfier),
payload: json!({
"configPath": config_path.as_ref().display().to_string(),
"serviceName": service_name.into(),
}),
}
}
pub fn helper_action_requires_elevation(action: &HelperAction) -> bool {
matches!(
action,
HelperAction::InstallControlApp
| HelperAction::InstallProxyfier
| HelperAction::InstallSingbox
| HelperAction::ProxyfierApply
| HelperAction::ServiceStart
| HelperAction::ServiceStop
| HelperAction::ServiceRestart
)
}
+125 -6
View File
@@ -4,19 +4,25 @@ pub mod apply_flow;
pub mod clock;
pub mod command_dto;
pub mod commands;
pub mod component_catalog;
pub mod component_cutover;
pub mod component_detection;
pub mod component_inventory;
pub mod component_packages;
pub mod component_status;
pub mod configuration_transaction;
pub mod configuration_use_case;
pub mod elevated_scripts;
pub mod helper;
pub mod migration;
pub mod models;
mod powershell;
pub mod nsis_runtime;
pub mod privileged_jobs;
pub mod privileged_runtime;
pub mod process;
pub mod proxifyre_ownership;
pub mod proxifyre_runtime;
pub mod proxifyre_scripts;
pub mod proxy_apply;
pub mod proxy_probe;
pub mod route_state;
pub mod safe_fs;
pub mod singbox_config;
pub mod singbox_runtime;
@@ -26,6 +32,113 @@ pub mod storage;
pub mod subscription;
pub mod validation;
pub enum EarlyProcessMode {
NotHandled,
Exit(i32),
}
/// Handles the fixed elevated-helper mode before Tauri or a webview is initialized.
/// Ordinary startup returns before constructing any component/network runtime.
pub fn run_early_process_mode<I>(arguments: I) -> EarlyProcessMode
where
I: IntoIterator<Item = std::ffi::OsString>,
{
let arguments = arguments.into_iter().collect::<Vec<_>>();
match nsis_runtime::parse_nsis_early_arguments(arguments.clone()) {
Ok(Some(mode)) => {
return EarlyProcessMode::Exit(nsis_runtime::nsis_process_exit_code(
nsis_runtime::run_system_nsis_mode(mode),
));
}
Ok(None) => {}
Err(_) => return EarlyProcessMode::Exit(nsis_runtime::NSIS_EXIT_USAGE),
}
let job_id = match privileged_jobs::parse_early_helper_arguments(arguments) {
Ok(Some(job_id)) => job_id,
Ok(None) => return EarlyProcessMode::NotHandled,
Err(_) => return EarlyProcessMode::Exit(64),
};
let runtime = match privileged_runtime::SystemPrivilegedRuntime::production() {
Ok(runtime) => runtime,
Err(_) => return EarlyProcessMode::Exit(2),
};
run_recognized_early_job(&job_id, &runtime, &runtime)
}
pub fn run_early_process_mode_with_runtime<I>(
arguments: I,
resolver: &dyn privileged_jobs::PrivilegedPlanResolver,
runner: &dyn privileged_jobs::PrivilegedActionRunner,
) -> EarlyProcessMode
where
I: IntoIterator<Item = std::ffi::OsString>,
{
let job_id = match privileged_jobs::parse_early_helper_arguments(arguments) {
Ok(Some(job_id)) => job_id,
Ok(None) => return EarlyProcessMode::NotHandled,
Err(_) => return EarlyProcessMode::Exit(64),
};
run_recognized_early_job(&job_id, resolver, runner)
}
fn run_recognized_early_job(
job_id: &privileged_jobs::PrivilegedJobId,
resolver: &dyn privileged_jobs::PrivilegedPlanResolver,
runner: &dyn privileged_jobs::PrivilegedActionRunner,
) -> EarlyProcessMode {
let store = match privileged_jobs::PrivilegedJobStore::production() {
Ok(store) => store,
Err(_) => return EarlyProcessMode::Exit(2),
};
let result = privileged_jobs::execute_privileged_job(
&store,
job_id,
&privileged_jobs::SystemEpochClock,
&privileged_jobs::NativeElevationProbe,
resolver,
runner,
);
match result {
Ok(result) if result.status == privileged_jobs::PrivilegedJobStatus::Succeeded => {
EarlyProcessMode::Exit(0)
}
Ok(_) => EarlyProcessMode::Exit(1),
Err(_) => EarlyProcessMode::Exit(2),
}
}
#[cfg(test)]
mod early_process_mode_tests {
use super::*;
#[test]
fn ordinary_startup_returns_before_constructing_privileged_runtime() {
assert!(matches!(
run_early_process_mode(Vec::<std::ffi::OsString>::new()),
EarlyProcessMode::NotHandled
));
}
#[test]
fn malformed_helper_arguments_fail_before_runtime_construction() {
assert!(matches!(
run_early_process_mode([std::ffi::OsString::from("--elevated-helper")]),
EarlyProcessMode::Exit(64)
));
}
#[test]
fn malformed_nsis_arguments_fail_before_runtime_construction() {
assert!(matches!(
run_early_process_mode([
std::ffi::OsString::from(nsis_runtime::NSIS_VERIFY_UPGRADE_ARGUMENT),
std::ffi::OsString::from("unexpected"),
]),
EarlyProcessMode::Exit(nsis_runtime::NSIS_EXIT_USAGE)
));
}
}
pub mod adapters {
pub mod proxifyre;
pub mod proxy_router;
@@ -42,10 +155,16 @@ pub fn run() {
commands::get_saved_state,
commands::get_components,
commands::get_proxifyre_setup_status,
commands::get_proxifyre_setup_progress,
commands::get_singbox_status,
commands::get_singbox_setup_status,
commands::save_singbox_subscription,
commands::get_component_package_statuses,
commands::get_component_cutover_statuses,
commands::check_component_update,
commands::download_component_update,
commands::update_component,
commands::cutover_component,
commands::confirm_component_route_smoke,
commands::cleanup_component_quarantine,
commands::fetch_singbox_subscription,
commands::forget_singbox_subscription,
commands::select_singbox_server,
+4
View File
@@ -1,5 +1,9 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
match proxywarden_lib::run_early_process_mode(std::env::args_os().skip(1)) {
proxywarden_lib::EarlyProcessMode::NotHandled => {}
proxywarden_lib::EarlyProcessMode::Exit(code) => std::process::exit(code),
}
proxywarden_lib::run();
}
File diff suppressed because it is too large Load Diff
+26 -23
View File
@@ -1,6 +1,5 @@
use percent_encoding::percent_decode_str;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use url::Url;
pub const DEFAULT_LOCAL_SINGBOX_LISTEN_HOST: &str = "127.0.0.1";
@@ -160,12 +159,36 @@ pub struct LocalSingBoxConfig {
pub listen_port: u16,
#[serde(default = "default_local_singbox_service_name")]
pub service_name: String,
#[serde(default = "default_local_singbox_install_root")]
#[serde(default = "default_local_singbox_install_root", skip_serializing)]
pub install_root: String,
#[serde(default)]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StorageMigrationOutcome {
InitializedEmpty,
AdoptedWithoutLegacyImport,
ImportedLegacyConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct StorageMeta {
pub storage_schema_version: u32,
pub outcome: StorageMigrationOutcome,
pub migration_id: String,
pub completed_at_epoch_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ComponentLayoutMeta {
pub component_layout_version: u32,
pub verified_at_epoch_seconds: u64,
}
impl LocalSingBoxConfig {
pub fn subscription_display_url(&self) -> Option<String> {
self.subscription_url
@@ -213,27 +236,7 @@ impl SubscriptionCache {
server.ensure_id();
}
let Some(outbounds) = self
.config
.get_mut("outbounds")
.and_then(Value::as_array_mut)
else {
return;
};
for outbound in outbounds {
let Some(decoded_tag) = outbound
.get("tag")
.and_then(Value::as_str)
.map(decode_percent_encoded_utf8)
else {
continue;
};
if let Some(object) = outbound.as_object_mut() {
object.insert("tag".to_string(), Value::String(decoded_tag));
}
}
// Outbound bytes define stable identity. Decode display labels only.
}
}
File diff suppressed because it is too large Load Diff
+993
View File
@@ -0,0 +1,993 @@
use super::*;
use crate::privileged_jobs::{
verify_nsis_privileged_lifecycle_idle_for_tests, write_nsis_interrupted_retirement_for_tests,
write_nsis_partial_reboot_staging_for_tests, write_nsis_partial_retirement_staging_for_tests,
write_nsis_terminal_pair_for_tests, NsisPrivilegedLifecycleGuard, NsisPrivilegedLifecycleState,
PrivilegedJobsError,
};
use std::cell::RefCell;
use std::collections::VecDeque;
#[derive(Default)]
struct FakeHost {
elevated: bool,
calls: RefCell<Vec<&'static str>>,
verify_executable: VecDeque<Result<(), NsisRuntimeError>>,
lifecycle_state: VecDeque<Result<NsisLifecycleState, NsisRuntimeError>>,
cutover: VecDeque<Result<NsisCutoverState, NsisRuntimeError>>,
proxifyre: VecDeque<Result<NsisComponentState, NsisRuntimeError>>,
singbox: VecDeque<Result<NsisComponentState, NsisRuntimeError>>,
transients: VecDeque<Result<NsisTransientState, NsisRuntimeError>>,
acquire: VecDeque<Result<(), NsisRuntimeError>>,
retire: VecDeque<Result<(), NsisRuntimeError>>,
stop_proxifyre: VecDeque<Result<(), NsisRuntimeError>>,
stop_singbox: VecDeque<Result<(), NsisRuntimeError>>,
retry_singbox_cleanup: VecDeque<Result<(), NsisRuntimeError>>,
uninstall_proxifyre: VecDeque<Result<bool, NsisRuntimeError>>,
uninstall_singbox: VecDeque<Result<bool, NsisRuntimeError>>,
reboot_under_lock: VecDeque<Result<bool, NsisRuntimeError>>,
mark_reboot: VecDeque<Result<bool, NsisRuntimeError>>,
clear_reboot: VecDeque<Result<(), NsisRuntimeError>>,
cleanup: VecDeque<Result<(), NsisRuntimeError>>,
}
impl FakeHost {
fn ready(proxifyre: NsisComponentState, singbox: NsisComponentState) -> Self {
Self {
elevated: true,
verify_executable: VecDeque::from([Ok(()), Ok(())]),
lifecycle_state: VecDeque::from([Ok(NsisLifecycleState {
retirement_pending: false,
reboot_required: false,
})]),
cutover: VecDeque::from([Ok(NsisCutoverState::Absent), Ok(NsisCutoverState::Absent)]),
proxifyre: VecDeque::from([Ok(proxifyre), Ok(proxifyre)]),
singbox: VecDeque::from([Ok(singbox), Ok(singbox)]),
transients: VecDeque::from([
Ok(NsisTransientState {
singbox_cleanup_pending: false,
package_staging_pending: false,
}),
Ok(NsisTransientState {
singbox_cleanup_pending: false,
package_staging_pending: false,
}),
]),
acquire: VecDeque::from([Ok(())]),
retire: VecDeque::from([Ok(())]),
stop_proxifyre: VecDeque::from([Ok(())]),
stop_singbox: VecDeque::from([Ok(())]),
retry_singbox_cleanup: VecDeque::from([Ok(())]),
uninstall_proxifyre: VecDeque::from([Ok(false)]),
uninstall_singbox: VecDeque::from([Ok(false)]),
reboot_under_lock: VecDeque::from([Ok(false)]),
mark_reboot: VecDeque::from([Ok(true), Ok(true)]),
clear_reboot: VecDeque::from([Ok(()), Ok(())]),
cleanup: VecDeque::from([Ok(())]),
..Self::default()
}
}
fn call(&self, name: &'static str) {
self.calls.borrow_mut().push(name);
}
fn calls(&self) -> Vec<&'static str> {
self.calls.borrow().clone()
}
}
fn next<T>(queue: &mut VecDeque<Result<T, NsisRuntimeError>>) -> Result<T, NsisRuntimeError> {
queue.pop_front().expect("fake call was not planned")
}
impl NsisRuntimeHost for FakeHost {
fn is_elevated(&self) -> bool {
self.call("elevated");
self.elevated
}
fn verify_current_executable(&mut self) -> Result<(), NsisRuntimeError> {
self.call("verify-exe");
next(&mut self.verify_executable)
}
fn verify_lifecycle_state(&mut self) -> Result<NsisLifecycleState, NsisRuntimeError> {
self.call("lifecycle-idle");
next(&mut self.lifecycle_state)
}
fn acquire_lifecycle_lock(&mut self) -> Result<(), NsisRuntimeError> {
self.call("acquire");
next(&mut self.acquire)
}
fn inspect_cutover(&mut self) -> Result<NsisCutoverState, NsisRuntimeError> {
self.call("cutover");
next(&mut self.cutover)
}
fn preflight_proxifyre(&mut self) -> Result<NsisComponentState, NsisRuntimeError> {
self.call("proxifyre");
next(&mut self.proxifyre)
}
fn preflight_singbox(&mut self) -> Result<NsisComponentState, NsisRuntimeError> {
self.call("singbox");
next(&mut self.singbox)
}
fn verify_transient_layout(&mut self) -> Result<NsisTransientState, NsisRuntimeError> {
self.call("transients");
next(&mut self.transients)
}
fn retire_cutover(
&mut self,
_expected: &CutoverTerminalRetirementExpectation,
) -> Result<(), NsisRuntimeError> {
self.call("retire-cutover");
next(&mut self.retire)
}
fn stop_proxifyre(&mut self) -> Result<(), NsisRuntimeError> {
self.call("stop-proxifyre");
next(&mut self.stop_proxifyre)
}
fn stop_singbox(&mut self) -> Result<(), NsisRuntimeError> {
self.call("stop-singbox");
next(&mut self.stop_singbox)
}
fn retry_singbox_cleanup(&mut self) -> Result<(), NsisRuntimeError> {
self.call("retry-singbox-cleanup");
next(&mut self.retry_singbox_cleanup)
}
fn uninstall_proxifyre(&mut self) -> Result<bool, NsisRuntimeError> {
self.call("uninstall-proxifyre");
next(&mut self.uninstall_proxifyre)
}
fn uninstall_singbox(&mut self) -> Result<bool, NsisRuntimeError> {
self.call("uninstall-singbox");
next(&mut self.uninstall_singbox)
}
fn reboot_required_under_lock(&mut self) -> Result<bool, NsisRuntimeError> {
self.call("reboot-under-lock");
next(&mut self.reboot_under_lock)
}
fn mark_reboot_required(&mut self) -> Result<bool, NsisRuntimeError> {
self.call("mark-reboot");
next(&mut self.mark_reboot)
}
fn clear_reboot_required(&mut self) -> Result<(), NsisRuntimeError> {
self.call("clear-reboot");
next(&mut self.clear_reboot)
}
fn cleanup_transients(&mut self) -> Result<(), NsisRuntimeError> {
self.call("cleanup");
next(&mut self.cleanup)
}
}
#[test]
fn parser_accepts_only_exact_single_nsis_flags() {
assert_eq!(
parse_nsis_early_arguments([OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT)])
.expect("verify flag"),
Some(NsisEarlyMode::VerifyUpgrade)
);
assert_eq!(
parse_nsis_early_arguments([OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT)])
.expect("uninstall flag"),
Some(NsisEarlyMode::UninstallManaged)
);
assert_eq!(
parse_nsis_early_arguments(Vec::<OsString>::new()).expect("ordinary launch"),
None
);
assert_eq!(
parse_nsis_early_arguments([OsString::from("--elevated-helper")])
.expect("other early mode"),
None
);
for invalid in [
vec![OsString::from(format!("{}{}", "--nsis-", "unknown"))],
vec![
OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT),
OsString::from("extra"),
],
vec![
OsString::from(NSIS_VERIFY_UPGRADE_ARGUMENT),
OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT),
],
vec![
OsString::from("ordinary"),
OsString::from(NSIS_UNINSTALL_MANAGED_ARGUMENT),
],
] {
assert_eq!(
parse_nsis_early_arguments(invalid),
Err(NsisRuntimeError::InvalidArguments)
);
}
}
#[test]
fn package_staging_recovery_accepts_only_fixed_component_uuid_and_entry_shapes() {
let uuid = "6f21e8c7-b63f-4c4c-9aa7-df96a7d0049d";
assert_eq!(
parse_package_staging_directory_name(&format!(".package-proxifyre-{uuid}")),
Ok(PackageStagingComponent::Proxifyre)
);
assert_eq!(
parse_package_staging_directory_name(&format!(".package-windows-packet-filter-{uuid}")),
Ok(PackageStagingComponent::WindowsPacketFilter)
);
assert_eq!(
parse_package_staging_directory_name(&format!(".package-sing-box-{uuid}")),
Ok(PackageStagingComponent::SingBox)
);
for invalid in [
".package-proxifyre-not-a-uuid",
".package-vc-runtime-6f21e8c7-b63f-4c4c-9aa7-df96a7d0049d",
".package-proxifyre-6F21E8C7-B63F-4C4C-9AA7-DF96A7D0049D",
] {
assert_eq!(
parse_package_staging_directory_name(invalid),
Err(NsisRuntimeError::TransientUnsafe)
);
}
assert!(package_staging_entry_role(
PackageStagingComponent::Proxifyre,
"ProxiFyre-v2.5.1-x64-signed.zip"
)
.is_some());
assert!(package_staging_entry_role(
PackageStagingComponent::WindowsPacketFilter,
"Windows.Packet.Filter.3.7.0.1.x64.msi"
)
.is_some());
assert!(package_staging_entry_role(
PackageStagingComponent::SingBox,
"sing-box-1.14.0-windows-amd64.zip"
)
.is_some());
assert!(package_staging_entry_role(PackageStagingComponent::SingBox, "foreign.zip").is_none());
}
#[test]
fn elevation_failure_returns_before_runtime_or_filesystem_checks() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.elevated = false;
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::NotElevated)
);
assert_eq!(host.calls(), ["elevated"]);
}
#[test]
fn upgrade_is_strictly_read_only() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedStopped,
);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
Ok(NsisRunOutcome::Success)
);
assert_eq!(
host.calls(),
[
"elevated",
"verify-exe",
"lifecycle-idle",
"cutover",
"proxifyre",
"singbox",
"transients",
]
);
}
#[test]
fn upgrade_blocks_terminal_cutover_without_retiring_it() {
let expected = CutoverTerminalRetirementExpectation::EmptyInfrastructure;
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.cutover = VecDeque::from([Ok(NsisCutoverState::Retirable(expected))]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
Err(NsisRuntimeError::CutoverBlocked)
);
assert!(!host.calls().contains(&"retire-cutover"));
assert!(!host.calls().contains(&"acquire"));
}
#[test]
fn upgrade_blocks_pending_tombstone_without_retrying_it() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.transients = VecDeque::from([Ok(NsisTransientState {
singbox_cleanup_pending: true,
package_staging_pending: false,
})]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
Err(NsisRuntimeError::TransientUnsafe)
);
assert!(!host.calls().contains(&"retry-singbox-cleanup"));
assert!(!host.calls().contains(&"acquire"));
}
#[test]
fn upgrade_blocks_interrupted_job_store_retirement_without_mutating_it() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState {
retirement_pending: true,
reboot_required: false,
})]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::VerifyUpgrade),
Err(NsisRuntimeError::TransientUnsafe)
);
assert!(!host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"cleanup"));
}
#[test]
fn full_uninstall_resumes_interrupted_job_store_retirement() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState {
retirement_pending: true,
reboot_required: false,
})]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
assert!(host.calls().contains(&"acquire"));
assert!(host.calls().contains(&"cleanup"));
}
#[test]
fn update_blocks_stale_package_staging_but_uninstall_retires_it() {
let pending = NsisTransientState {
singbox_cleanup_pending: false,
package_staging_pending: true,
};
let mut update = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
update.transients = VecDeque::from([Ok(pending)]);
assert_eq!(
run_nsis_mode(&mut update, NsisEarlyMode::VerifyUpgrade),
Err(NsisRuntimeError::TransientUnsafe)
);
assert!(!update.calls().contains(&"cleanup"));
let mut uninstall = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
uninstall.transients = VecDeque::from([Ok(pending), Ok(pending)]);
assert_eq!(
run_nsis_mode(&mut uninstall, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
assert!(uninstall.calls().contains(&"cleanup"));
}
#[test]
fn unsafe_first_component_still_preflights_second_and_causes_zero_mutation() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.proxifyre = VecDeque::from([Err(NsisRuntimeError::ComponentUnsafe)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::ComponentUnsafe)
);
assert!(host.calls().contains(&"singbox"));
assert!(host.calls().contains(&"transients"));
assert!(!host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"stop-proxifyre"));
assert!(!host.calls().contains(&"uninstall-singbox"));
}
#[test]
fn busy_lifecycle_still_runs_full_read_only_preflight_and_never_mutates() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedStopped,
);
host.lifecycle_state = VecDeque::from([Err(NsisRuntimeError::LifecycleBusy)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::LifecycleBusy)
);
assert!(host.calls().contains(&"proxifyre"));
assert!(host.calls().contains(&"singbox"));
assert!(host.calls().contains(&"transients"));
assert!(!host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"stop-proxifyre"));
}
#[test]
fn uninstall_stops_both_running_services_before_uninstalling_either() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedRunning,
);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
let stop_prox = calls
.iter()
.position(|call| *call == "stop-proxifyre")
.unwrap();
let stop_sing = calls
.iter()
.position(|call| *call == "stop-singbox")
.unwrap();
let uninstall_prox = calls
.iter()
.position(|call| *call == "uninstall-proxifyre")
.unwrap();
let uninstall_sing = calls
.iter()
.position(|call| *call == "uninstall-singbox")
.unwrap();
let cleanup = calls.iter().position(|call| *call == "cleanup").unwrap();
assert!(stop_prox < uninstall_prox);
assert!(stop_sing < uninstall_prox);
assert!(uninstall_prox < uninstall_sing);
assert!(uninstall_sing < cleanup);
}
#[test]
fn missing_components_are_noops_but_owned_transients_are_retired() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
assert!(!calls.contains(&"stop-proxifyre"));
assert!(!calls.contains(&"stop-singbox"));
assert!(!calls.contains(&"uninstall-proxifyre"));
assert!(!calls.contains(&"uninstall-singbox"));
assert!(calls.contains(&"cleanup"));
}
#[test]
fn state_drift_after_lock_causes_zero_component_mutation() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::Missing,
);
host.proxifyre = VecDeque::from([
Ok(NsisComponentState::ManagedRunning),
Ok(NsisComponentState::ManagedStopped),
]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::StateChanged)
);
assert!(host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"stop-proxifyre"));
assert!(!host.calls().contains(&"uninstall-proxifyre"));
}
#[test]
fn terminal_cutover_is_exactly_retired_before_component_mutation() {
let expected = CutoverTerminalRetirementExpectation::EmptyInfrastructure;
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::Missing,
);
host.cutover = VecDeque::from([
Ok(NsisCutoverState::Retirable(expected.clone())),
Ok(NsisCutoverState::Retirable(expected)),
Ok(NsisCutoverState::Absent),
]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
let retire = calls
.iter()
.position(|call| *call == "retire-cutover")
.unwrap();
let stop = calls
.iter()
.position(|call| *call == "stop-proxifyre")
.unwrap();
assert!(retire < stop);
}
#[test]
fn stop_failure_prevents_all_uninstall_and_terminal_cleanup() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedRunning,
);
host.stop_proxifyre = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::OperationFailed)
);
let calls = host.calls();
assert!(!calls.contains(&"stop-singbox"));
assert!(!calls.contains(&"uninstall-proxifyre"));
assert!(!calls.contains(&"cleanup"));
}
#[test]
fn uninstall_reboot_requirement_maps_to_msi_3010() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::ManagedStopped,
);
host.uninstall_proxifyre = VecDeque::from([Ok(true)]);
assert_eq!(
nsis_process_exit_code(run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged)),
NSIS_EXIT_REBOOT_REQUIRED
);
assert_eq!(
nsis_process_exit_code(Err(NsisRuntimeError::InvalidArguments)),
NSIS_EXIT_USAGE
);
let calls = host.calls();
assert!(
calls.iter().position(|call| *call == "mark-reboot")
< calls.iter().position(|call| *call == "uninstall-proxifyre")
);
assert!(!calls.contains(&"clear-reboot"));
}
#[test]
fn reboot_intent_is_write_ahead_and_cleared_only_after_proven_no_reboot() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::Missing,
);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
let mark = calls
.iter()
.position(|call| *call == "mark-reboot")
.unwrap();
let uninstall = calls
.iter()
.position(|call| *call == "uninstall-proxifyre")
.unwrap();
let clear = calls
.iter()
.position(|call| *call == "clear-reboot")
.unwrap();
assert!(mark < uninstall);
assert!(uninstall < clear);
}
#[test]
fn failed_uninstall_keeps_write_ahead_reboot_intent_for_retry() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::Missing,
);
host.uninstall_proxifyre = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::OperationFailed)
);
let calls = host.calls();
assert!(
calls.iter().position(|call| *call == "mark-reboot")
< calls.iter().position(|call| *call == "uninstall-proxifyre")
);
assert!(!calls.contains(&"clear-reboot"));
assert!(!calls.contains(&"cleanup"));
}
#[test]
fn intent_published_while_waiting_for_lock_is_never_adopted_or_cleared() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::Missing,
);
// The read-only pre-lock probe saw no marker, but authoritative observation
// under the acquired lock sees the earlier owner's durable fact.
host.reboot_under_lock = VecDeque::from([Ok(true)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::RebootRequired)
);
let calls = host.calls();
assert!(calls.contains(&"uninstall-proxifyre"));
assert!(!calls.contains(&"mark-reboot"));
assert!(!calls.contains(&"clear-reboot"));
}
#[test]
fn marker_published_after_probe_is_delivered_even_when_components_are_missing() {
let mut host = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
host.reboot_under_lock = VecDeque::from([Ok(true)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::RebootRequired)
);
let calls = host.calls();
assert!(calls.contains(&"reboot-under-lock"));
assert!(!calls.contains(&"uninstall-proxifyre"));
assert!(!calls.contains(&"uninstall-singbox"));
assert!(!calls.contains(&"clear-reboot"));
}
#[test]
fn reboot_requirement_survives_a_later_failure_and_retry() {
let mut first = FakeHost::ready(
NsisComponentState::ManagedStopped,
NsisComponentState::ManagedStopped,
);
first.uninstall_proxifyre = VecDeque::from([Ok(true)]);
first.uninstall_singbox = VecDeque::from([Err(NsisRuntimeError::OperationFailed)]);
assert_eq!(
run_nsis_mode(&mut first, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::OperationFailed)
);
let calls = first.calls();
assert!(
calls.iter().position(|call| *call == "mark-reboot")
< calls.iter().position(|call| *call == "uninstall-singbox")
);
assert!(!calls.contains(&"cleanup"));
let mut retry = FakeHost::ready(NsisComponentState::Missing, NsisComponentState::Missing);
retry.lifecycle_state = VecDeque::from([Ok(NsisLifecycleState {
retirement_pending: false,
reboot_required: true,
})]);
retry.reboot_under_lock = VecDeque::from([Ok(true)]);
assert_eq!(
run_nsis_mode(&mut retry, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::RebootRequired)
);
assert!(!retry.calls().contains(&"mark-reboot"));
assert!(retry.calls().contains(&"cleanup"));
}
#[test]
fn transient_shape_failure_is_observed_before_lock_and_component_mutation() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::ManagedStopped,
);
host.transients = VecDeque::from([Err(NsisRuntimeError::TransientUnsafe)]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Err(NsisRuntimeError::TransientUnsafe)
);
assert!(!host.calls().contains(&"acquire"));
assert!(!host.calls().contains(&"stop-proxifyre"));
}
#[test]
fn pending_singbox_tombstone_is_retried_before_services_are_stopped() {
let mut host = FakeHost::ready(
NsisComponentState::ManagedRunning,
NsisComponentState::Missing,
);
host.transients = VecDeque::from([
Ok(NsisTransientState {
singbox_cleanup_pending: true,
package_staging_pending: false,
}),
Ok(NsisTransientState {
singbox_cleanup_pending: true,
package_staging_pending: false,
}),
Ok(NsisTransientState {
singbox_cleanup_pending: false,
package_staging_pending: false,
}),
]);
assert_eq!(
run_nsis_mode(&mut host, NsisEarlyMode::UninstallManaged),
Ok(NsisRunOutcome::Success)
);
let calls = host.calls();
let retry = calls
.iter()
.position(|call| *call == "retry-singbox-cleanup")
.unwrap();
let stop = calls
.iter()
.position(|call| *call == "stop-proxifyre")
.unwrap();
assert!(retry < stop);
}
#[test]
fn hook_separates_update_from_full_uninstall_without_powershell() {
let hook = include_str!("../bundled/installer-hooks/proxywarden-hooks.nsh");
assert!(hook.contains("$UpdateMode"));
assert!(hook.contains(NSIS_VERIFY_UPGRADE_ARGUMENT));
assert!(hook.contains(NSIS_UNINSTALL_MANAGED_ARGUMENT));
assert!(hook.contains("CheckIfAppIsRunning"));
assert!(hook.contains("3010"));
assert!(hook.contains("SetRebootFlag true"));
assert_eq!(hook.matches("ClearErrors").count(), 3);
let launch_error_gate = hook.find("IfErrors").expect("launch-error gate");
let last_exec = hook.rfind("ExecWait").expect("native helper launch");
assert!(last_exec < launch_error_gate);
for branch in hook.split("ExecWait").take(2) {
assert!(branch.rfind("ClearErrors").is_some());
}
assert!(!hook.to_ascii_lowercase().contains("powershell"));
let guard = hook.find("CheckIfAppIsRunning").expect("app guard");
let destructive = hook
.find(NSIS_UNINSTALL_MANAGED_ARGUMENT)
.expect("destructive mode");
assert!(guard < destructive);
let reboot_observed = hook.find("SetRebootFlag true").expect("reboot flag");
let marker_ack = hook
.find("Delete \"$INSTDIR\\.proxywarden-nsis-reboot-required.json\"")
.expect("exact reboot marker acknowledgement");
let marker_error = hook[marker_ack..]
.find("IfErrors")
.map(|offset| marker_ack + offset)
.expect("marker delete error gate");
assert!(reboot_observed < marker_ack);
assert!(marker_ack < marker_error);
assert!(hook[marker_error..].contains("Abort"));
}
#[cfg(all(windows, debug_assertions))]
mod windows_store {
use super::*;
use std::fs;
struct TestRoot(PathBuf);
impl TestRoot {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"proxywarden-nsis-store-{}",
uuid::Uuid::new_v4().hyphenated()
));
fs::create_dir(&path).expect("create temp app root");
Self(path)
}
}
impl Drop for TestRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
#[test]
fn read_only_idle_probe_does_not_create_store() {
let root = TestRoot::new();
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0).expect("idle missing store"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: false,
}
);
assert!(!root.0.join(".proxywarden-privileged-jobs").exists());
}
#[test]
fn exact_terminal_pairs_and_lock_are_retired_nonrecursively() {
let root = TestRoot::new();
write_nsis_terminal_pair_for_tests(&root.0, true).expect("terminal pair");
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0).expect("terminal idle store"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: false,
}
);
let held = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("exclusive lifecycle guard");
assert!(matches!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0),
Err(PrivilegedJobsError::LifecycleBusy)
));
drop(held);
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("persisted idle lock is read-only verifiable");
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("reacquire lifecycle guard");
match guard.retire_terminal_store() {
Ok(()) => {}
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied =>
{
// Stable identity leases capture SACL bytes. A normal
// developer token cannot enable SeSecurityPrivilege; the
// elevated NSIS path and elevated Windows gate exercise the
// actual same-handle deletion.
return;
}
Err(error) => panic!("exact retirement: {error}"),
}
assert!(!root.0.join(".proxywarden-privileged-jobs").exists());
}
#[test]
fn interrupted_terminal_retirement_is_detected_and_resumed() {
let root = TestRoot::new();
write_nsis_terminal_pair_for_tests(&root.0, true).expect("independent terminal pair");
write_nsis_interrupted_retirement_for_tests(&root.0)
.expect("interrupted retirement fixture");
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("durable retirement marker"),
NsisPrivilegedLifecycleState {
retirement_pending: true,
reboot_required: false,
}
);
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("resume lifecycle guard");
match guard.retire_terminal_store() {
Ok(()) => {
assert!(!root.0.join(".proxywarden-privileged-jobs").exists());
}
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
Err(error) => panic!("resumed retirement: {error}"),
}
}
#[test]
fn reboot_marker_survives_store_retirement_until_nsis_observes_3010() {
let root = TestRoot::new();
let guard =
NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0).expect("lifecycle guard");
assert!(guard.mark_reboot_required().expect("durable reboot marker"));
drop(guard);
assert!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("reboot state")
.reboot_required
);
fs::remove_dir_all(root.0.join(".proxywarden-privileged-jobs"))
.expect("simulate completed store cleanup before process exit");
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("reboot survives store loss"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: true,
}
);
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("recreate exact lifecycle store");
match guard.retire_terminal_store() {
Ok(()) => assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("reboot marker retained for outward 3010"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: true,
}
),
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
Err(error) => panic!("reboot marker retirement: {error}"),
}
}
#[test]
fn proven_no_reboot_clears_only_the_exact_write_ahead_marker() {
let root = TestRoot::new();
let guard =
NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0).expect("lifecycle guard");
assert!(guard.mark_reboot_required().expect("write-ahead intent"));
guard
.clear_reboot_required()
.expect("exact no-reboot acknowledgement");
drop(guard);
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("marker cleared after proven no-reboot"),
NsisPrivilegedLifecycleState {
retirement_pending: false,
reboot_required: false,
}
);
}
#[test]
fn partial_retirement_staging_is_durable_and_resumed_pair_at_a_time() {
let root = TestRoot::new();
write_nsis_partial_retirement_staging_for_tests(&root.0)
.expect("partial retirement staging");
assert_eq!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("staging is a durable retirement intent"),
NsisPrivilegedLifecycleState {
retirement_pending: true,
reboot_required: false,
}
);
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("resume staged retirement");
match guard.retire_terminal_store() {
Ok(()) => assert!(!root.0.join(".proxywarden-privileged-jobs").exists()),
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
Err(error) => panic!("staged retirement recovery: {error}"),
}
}
#[test]
fn partial_reboot_staging_is_published_and_never_acknowledged_by_helper() {
let root = TestRoot::new();
write_nsis_partial_reboot_staging_for_tests(&root.0).expect("partial reboot staging");
assert!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("partial reboot intent")
.reboot_required
);
let guard = NsisPrivilegedLifecycleGuard::acquire_for_tests(&root.0)
.expect("recover reboot marker under lifecycle lock");
assert!(!root
.0
.join(".proxywarden-nsis-reboot-required.pending")
.exists());
assert!(root
.0
.join(".proxywarden-nsis-reboot-required.json")
.is_file());
match guard.retire_terminal_store() {
Ok(()) => assert!(
verify_nsis_privileged_lifecycle_idle_for_tests(&root.0)
.expect("reboot fact remains after store cleanup")
.reboot_required
),
Err(PrivilegedJobsError::Io(error))
if error.kind() == std::io::ErrorKind::PermissionDenied => {}
Err(error) => panic!("reboot staging recovery: {error}"),
}
}
#[test]
fn running_or_unknown_records_block_without_deletion() {
let running = TestRoot::new();
write_nsis_terminal_pair_for_tests(&running.0, false).expect("running pair");
assert!(matches!(
verify_nsis_privileged_lifecycle_idle_for_tests(&running.0),
Err(PrivilegedJobsError::InvalidRecord)
));
assert!(running.0.join(".proxywarden-privileged-jobs").exists());
let unknown = TestRoot::new();
write_nsis_terminal_pair_for_tests(&unknown.0, true).expect("terminal pair");
let path = unknown
.0
.join(".proxywarden-privileged-jobs")
.join("foreign.bin");
fs::write(&path, b"foreign").expect("foreign entry");
safe_fs::protect_path_for_owner_admin_system(&path).expect("seal fixture");
assert!(matches!(
verify_nsis_privileged_lifecycle_idle_for_tests(&unknown.0),
Err(PrivilegedJobsError::InvalidRecord)
));
assert!(path.exists());
}
}
-120
View File
@@ -1,120 +0,0 @@
//! Shared PowerShell execution boundary for fixed ProxyWarden scripts.
//!
//! Callers remain responsible for generating static script templates and for
//! validating every path or service identifier before invoking this module.
use crate::process::command_no_window;
use std::{fs, path::Path, process::Output};
pub(crate) fn write_script(path: &Path, script: &str) -> std::io::Result<()> {
let mut bytes = Vec::with_capacity(script.len() + 3);
bytes.extend_from_slice(&[0xEF, 0xBB, 0xBF]);
bytes.extend_from_slice(script.as_bytes());
fs::write(path, bytes)
}
pub(crate) fn run_command(script: &str) -> std::io::Result<Output> {
command_no_window("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.output()
}
pub(crate) fn run_file(script_path: &Path) -> std::io::Result<Output> {
command_no_window("powershell")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
])
.arg(script_path)
.output()
}
pub(crate) fn is_elevated() -> bool {
if !cfg!(windows) {
return false;
}
let script = r#"([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"#;
let Ok(output) = run_command(script) else {
return false;
};
output.status.success()
&& String::from_utf8_lossy(&output.stdout)
.trim()
.eq_ignore_ascii_case("true")
}
pub(crate) fn output_message(output: &Output, fallback: &str) -> String {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if !stderr.is_empty() {
return stderr;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !stdout.is_empty() {
return stdout;
}
fallback.to_string()
}
pub(crate) fn package_failure_details(result_path: &Path, output: &Output) -> String {
let mut parts = Vec::new();
if let Ok(contents) = fs::read_to_string(result_path) {
let details = compact_error_text(&contents);
if !details.is_empty() && !details.eq_ignore_ascii_case("ok") {
parts.push(details);
}
}
let stdout = compact_error_text(&String::from_utf8_lossy(&output.stdout));
if !stdout.is_empty() {
parts.push(format!("stdout: {stdout}"));
}
let stderr = compact_error_text(&String::from_utf8_lossy(&output.stderr));
if !stderr.is_empty() {
parts.push(format!("stderr: {stderr}"));
}
if parts.is_empty() {
parts.push(
"Лог elevated-скрипта не создан. Обычно это значит, что окно UAC было отменено или Windows не дала запустить elevated PowerShell."
.to_string(),
);
}
parts.join(" ")
}
fn compact_error_text(value: &str) -> String {
let text = value
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
const MAX_CHARS: usize = 1400;
if text.chars().count() <= MAX_CHARS {
return text;
}
format!("{}...", text.chars().take(MAX_CHARS).collect::<String>())
}
pub(crate) fn escape_single(value: &str) -> String {
value.replace('\'', "''")
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3941 -1
View File
File diff suppressed because it is too large Load Diff
+52 -15
View File
@@ -23,6 +23,22 @@ struct ProxiFyreInstallMarker {
packet_filter_installed_by_proxy_warden: bool,
}
pub fn validate_proxifyre_marker_text(
marker_text: &str,
expected_install_dir: &Path,
) -> Result<ManagedProxiFyreOwnership, String> {
let marker = parse_marker(marker_text)?;
validate_marker_identity(&marker)?;
if !same_path(Path::new(&marker.install_root), expected_install_dir) {
return Err("installRoot из marker не совпадает с управляемой папкой".to_string());
}
Ok(ManagedProxiFyreOwnership {
service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_string(),
remove_packet_filter: marker.packet_filter_installed_by_proxy_warden,
})
}
pub fn verify_managed_proxifyre_install(
install_dir: &Path,
executable_path: &Path,
@@ -68,25 +84,13 @@ pub fn verify_managed_proxifyre_install(
marker_path.display()
)
})?;
let marker_text = marker_text.strip_prefix('\u{feff}').unwrap_or(&marker_text);
let marker: ProxiFyreInstallMarker = serde_json::from_str(marker_text).map_err(|error| {
let marker = parse_marker(&marker_text).map_err(|error| {
format!(
"marker установки {} содержит некорректный JSON: {error}",
"marker установки {} содержит некорректные данные: {error}",
marker_path.display()
)
})?;
if !marker.manager.eq_ignore_ascii_case("ProxyWarden")
|| !marker.component.eq_ignore_ascii_case("proxifyre")
{
return Err("marker установки не подтверждает владение ProxyWarden/ProxiFyre".to_string());
}
if !marker
.service_name
.eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME)
{
return Err("marker установки содержит неподдерживаемое имя службы".to_string());
}
validate_marker_identity(&marker)?;
let marker_root = canonical_path(Path::new(&marker.install_root), "installRoot из marker")?;
if marker_root != install_dir {
@@ -99,6 +103,39 @@ pub fn verify_managed_proxifyre_install(
})
}
fn parse_marker(marker_text: &str) -> Result<ProxiFyreInstallMarker, String> {
let marker_text = marker_text.strip_prefix('\u{feff}').unwrap_or(marker_text);
serde_json::from_str(marker_text)
.map_err(|error| format!("marker содержит некорректный JSON: {error}"))
}
fn validate_marker_identity(marker: &ProxiFyreInstallMarker) -> Result<(), String> {
if !marker.manager.eq_ignore_ascii_case("ProxyWarden")
|| !marker.component.eq_ignore_ascii_case("proxifyre")
{
return Err("marker установки не подтверждает владение ProxyWarden/ProxiFyre".to_string());
}
if !marker
.service_name
.eq_ignore_ascii_case(PROXIFYRE_MANAGED_SERVICE_NAME)
{
return Err("marker установки содержит неподдерживаемое имя службы".to_string());
}
Ok(())
}
fn same_path(left: &Path, right: &Path) -> bool {
left.to_string_lossy()
.replace('/', "\\")
.trim_end_matches('\\')
.eq_ignore_ascii_case(
right
.to_string_lossy()
.replace('/', "\\")
.trim_end_matches('\\'),
)
}
fn canonical_path(path: &Path, label: &str) -> Result<std::path::PathBuf, String> {
fs::canonicalize(path)
.map_err(|error| format!("не удалось проверить {label} '{}': {error}", path.display()))
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,921 @@
use super::*;
use crate::component_cutover::{
CutoverOperation, EffectDisposition, LegacyServiceState, MutationDirection, MutationEffect,
MutationRecord, StateFingerprint,
};
use crate::process::{
FullServiceSnapshot, ServiceBaseConfigSnapshot, ServiceSecuritySnapshot, ServiceStableState,
SERVICE_CONFIG2_KINDS,
};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Call {
CaptureLegacy,
QueryLegacy,
QueryCurrent,
QueryComplete,
QueryLegacyPolicy(ServiceConfig2Kind),
QueryCurrentPolicy(ServiceConfig2Kind),
QueryCurrentSecurity,
StopLegacy,
DeleteLegacy,
CreateCurrent,
SetCurrentPolicy(ServiceConfig2Kind),
SetCurrentSecurity,
StartCurrent,
StopCurrent,
DeleteCurrent,
CreateLegacy,
RestoreLegacyPolicy(ServiceConfig2Kind),
RestoreLegacySecurity,
StartLegacy,
}
struct FakeScm {
calls: Vec<Call>,
fail_on: Option<Call>,
before: ServiceRestoreSnapshot,
complete: CompleteServiceObservation,
current_base: ServiceBaseConfigSnapshot,
}
impl FakeScm {
fn new() -> Self {
let current_base = expected_current_proxifyre_service_base(
&std::env::temp_dir().join("ProxyWarden-current-ProxiFyre.exe"),
)
.expect("current base fixture");
Self {
calls: Vec::new(),
fail_on: None,
before: before_state(),
complete: CompleteServiceObservation::Missing,
current_base,
}
}
fn record(&mut self, call: Call) -> Result<(), ProxifyreNativeHostError> {
self.calls.push(call.clone());
if self.fail_on.as_ref() == Some(&call) {
Err(ProxifyreNativeHostError)
} else {
Ok(())
}
}
fn missing_policy() -> ServicePolicySnapshot {
ServicePolicySnapshot {
service: crate::process::ServiceSnapshot {
exists: false,
state: None,
path_name: None,
process_id: None,
},
path_matches: false,
demand_start: false,
failure_recovery_disabled: false,
dacl_matches: false,
}
}
}
impl ProxifyreCutoverScm for FakeScm {
fn capture_legacy_service(
&mut self,
) -> Result<ServiceRestoreSnapshot, ProxifyreNativeHostError> {
self.record(Call::CaptureLegacy)?;
Ok(self.before.clone())
}
fn query_legacy_service(&mut self) -> Result<ServicePolicySnapshot, ProxifyreNativeHostError> {
self.record(Call::QueryLegacy)?;
Ok(Self::missing_policy())
}
fn query_current_service(&mut self) -> Result<ServicePolicySnapshot, ProxifyreNativeHostError> {
self.record(Call::QueryCurrent)?;
Ok(Self::missing_policy())
}
fn query_complete_service(
&mut self,
) -> Result<CompleteServiceObservation, ProxifyreNativeHostError> {
self.record(Call::QueryComplete)?;
Ok(self.complete.clone())
}
fn expected_current_service_base(
&self,
) -> Result<ServiceBaseConfigSnapshot, ProxifyreNativeHostError> {
Ok(self.current_base.clone())
}
fn query_legacy_service_policy(
&mut self,
kind: ServiceConfig2Kind,
) -> Result<Option<ServiceConfig2Snapshot>, ProxifyreNativeHostError> {
self.record(Call::QueryLegacyPolicy(kind))?;
Ok(self.before.config2(kind).cloned())
}
fn query_current_service_policy(
&mut self,
kind: ServiceConfig2Kind,
) -> Result<Option<ServiceConfig2Snapshot>, ProxifyreNativeHostError> {
self.record(Call::QueryCurrentPolicy(kind))?;
Ok(None)
}
fn current_service_security_matches(&mut self) -> Result<bool, ProxifyreNativeHostError> {
self.record(Call::QueryCurrentSecurity)?;
Ok(false)
}
fn stop_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::StopLegacy)
}
fn delete_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::DeleteLegacy)
}
fn create_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::CreateCurrent)
}
fn set_current_service_policy(
&mut self,
kind: ServiceConfig2Kind,
) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::SetCurrentPolicy(kind))
}
fn set_current_service_security(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::SetCurrentSecurity)
}
fn start_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::StartCurrent)
}
fn stop_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::StopCurrent)
}
fn delete_current_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::DeleteCurrent)
}
fn create_legacy_service(
&mut self,
_before: &ServiceRestoreSnapshot,
) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::CreateLegacy)
}
fn restore_legacy_service_policy(
&mut self,
snapshot: &ServiceConfig2Snapshot,
) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::RestoreLegacyPolicy(snapshot.kind()))
}
fn restore_legacy_service_security(
&mut self,
_before: &ServiceRestoreSnapshot,
) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::RestoreLegacySecurity)
}
fn start_legacy_service(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(Call::StartLegacy)
}
}
fn before_state() -> ServiceRestoreSnapshot {
FullServiceSnapshot {
service_name: PROXIFYRE_MANAGED_SERVICE_NAME.to_owned(),
base: ServiceBaseConfigSnapshot {
service_type: 0x10,
start_type: 2,
error_control: 1,
binary_path_name: concat!(
r#""C:\Tools\ProxiFyre\ProxiFyre.exe" "#,
r#"-displayname "ProxiFyre Service" -servicename "ProxiFyreService""#
)
.to_owned(),
load_order_group: None,
tag_id: 0,
dependencies: Vec::new(),
service_start_name: "LocalSystem".to_owned(),
display_name: "ProxiFyre Service".to_owned(),
},
config2: SERVICE_CONFIG2_KINDS
.iter()
.copied()
.map(expected_current_proxifyre_service_policy)
.collect(),
security: ServiceSecuritySnapshot {
self_relative_descriptor: vec![1, 2, 3],
untrusted_mutation_rights: false,
},
original_state: ServiceStableState::Running,
}
}
#[test]
fn create_current_is_exactly_one_call_and_never_starts() {
let mut host = FakeScm::new();
let before = host.before.clone();
assert!(mutate_proxifyre_cutover_scm(
&mut host,
&CutoverOperation::CreateCurrentService,
&before,
)
.expect("SCM mutation dispatch"));
assert_eq!(host.calls, vec![Call::CreateCurrent]);
}
#[test]
fn collision_or_failure_stops_after_the_single_selected_mutation() {
let mut host = FakeScm::new();
host.fail_on = Some(Call::DeleteLegacy);
let before = host.before.clone();
mutate_proxifyre_cutover_scm(&mut host, &CutoverOperation::DeleteLegacyService, &before)
.expect_err("collision/failure must surface");
assert_eq!(host.calls, vec![Call::DeleteLegacy]);
}
#[test]
fn restore_policy_selects_only_the_requested_captured_record() {
let mut host = FakeScm::new();
let before = host.before.clone();
assert!(mutate_proxifyre_cutover_scm(
&mut host,
&CutoverOperation::RestoreLegacyServicePolicy(ServiceConfig2Kind::Triggers),
&before,
)
.expect("restore dispatch"));
assert_eq!(
host.calls,
vec![Call::RestoreLegacyPolicy(ServiceConfig2Kind::Triggers)]
);
}
#[test]
fn read_only_missing_policy_is_typed_absence_and_never_mutates() {
let mut host = FakeScm::new();
assert_eq!(
host.query_current_service_policy(ServiceConfig2Kind::Description)
.expect("read-only query"),
None
);
assert_eq!(
host.calls,
vec![Call::QueryCurrentPolicy(ServiceConfig2Kind::Description)]
);
}
#[test]
fn non_scm_operation_is_not_claimed_or_mutated() {
let mut host = FakeScm::new();
let before = host.before.clone();
assert!(!mutate_proxifyre_cutover_scm(
&mut host,
&CutoverOperation::HardenLegacyRootSecurity,
&before,
)
.expect("non-SCM dispatch"));
assert!(host.calls.is_empty());
}
#[test]
fn expected_current_policy_covers_every_config2_kind() {
for kind in SERVICE_CONFIG2_KINDS {
assert_eq!(expected_current_proxifyre_service_policy(kind).kind(), kind);
}
assert!(matches!(
expected_current_proxifyre_service_policy(ServiceConfig2Kind::Triggers),
ServiceConfig2Snapshot::Triggers(ref triggers) if triggers.is_empty()
));
}
#[test]
fn scm_observer_matches_typed_expected_fingerprint_for_every_scm_operation() {
let before = before_state();
let operations = vec![
CutoverOperation::StopLegacyService,
CutoverOperation::DeleteLegacyService,
CutoverOperation::CreateCurrentService,
CutoverOperation::SetCurrentServicePolicy(ServiceConfig2Kind::Description),
CutoverOperation::SetCurrentServiceSecurity,
CutoverOperation::StartCurrentService,
CutoverOperation::StopCurrentService,
CutoverOperation::DeleteCurrentService,
CutoverOperation::CreateLegacyService,
CutoverOperation::RestoreLegacyServicePolicy(ServiceConfig2Kind::Triggers),
CutoverOperation::RestoreLegacyServiceSecurity,
CutoverOperation::StartLegacyService,
];
for operation in operations {
let mut host = FakeScm::new();
host.complete = satisfying_scm_observation(&operation, &before, &host.current_base);
assert_eq!(
observe_proxifyre_cutover_scm_state(&mut host, &operation, &before)
.expect("typed complete SCM observation"),
expected_proxifyre_cutover_scm_effect(&operation).expect("typed expected SCM effect"),
"operation {operation:?}"
);
assert_eq!(host.calls, vec![Call::QueryComplete]);
}
}
#[test]
fn scm_unexpected_fingerprint_preserves_complete_drift_instead_of_boolean_bucket() {
let before = before_state();
let operation = CutoverOperation::CreateCurrentService;
let mut first = FakeScm::new();
let mut first_snapshot = before.clone();
first_snapshot.base.display_name = "foreign-one".to_owned();
first.complete = complete_service(first_snapshot, false);
let first_fingerprint = observe_proxifyre_cutover_scm_state(&mut first, &operation, &before)
.expect("first exact unexpected state");
let mut repeated = FakeScm::new();
let mut repeated_snapshot = before.clone();
repeated_snapshot.base.display_name = "foreign-one".to_owned();
repeated.complete = complete_service(repeated_snapshot, false);
let repeated_fingerprint =
observe_proxifyre_cutover_scm_state(&mut repeated, &operation, &before)
.expect("repeated exact unexpected state");
let mut second = FakeScm::new();
let mut second_snapshot = before.clone();
second_snapshot.base.display_name = "foreign-two".to_owned();
second.complete = complete_service(second_snapshot, false);
let second_fingerprint = observe_proxifyre_cutover_scm_state(&mut second, &operation, &before)
.expect("second exact unexpected state");
assert_eq!(first_fingerprint, repeated_fingerprint);
assert_ne!(first_fingerprint, second_fingerprint);
assert_ne!(
first_fingerprint,
expected_proxifyre_cutover_scm_effect(&operation).expect("expected effect")
);
}
#[test]
fn scm_expected_effect_rejects_untrusted_mutation_rights() {
let before = before_state();
let operation = CutoverOperation::CreateCurrentService;
let mut host = FakeScm::new();
let mut live = satisfying_scm_observation(&operation, &before, &host.current_base);
let CompleteServiceObservation::Present { snapshot, .. } = &mut live else {
panic!("current service fixture must be present");
};
snapshot.security.untrusted_mutation_rights = true;
host.complete = live;
assert_ne!(
observe_proxifyre_cutover_scm_state(&mut host, &operation, &before)
.expect("exact unsafe SCM observation"),
expected_proxifyre_cutover_scm_effect(&operation).expect("expected effect")
);
}
#[test]
fn create_current_effect_requires_the_complete_fresh_service_default_profile() {
let before = before_state();
let operation = CutoverOperation::CreateCurrentService;
let mut exact = FakeScm::new();
exact.complete = satisfying_scm_observation(&operation, &before, &exact.current_base);
assert_eq!(
observe_proxifyre_cutover_scm_state(&mut exact, &operation, &before)
.expect("complete fresh-service defaults"),
expected_proxifyre_cutover_scm_effect(&operation).expect("expected create effect")
);
let mut drifted = FakeScm::new();
let mut live = satisfying_scm_observation(&operation, &before, &drifted.current_base);
let CompleteServiceObservation::Present { snapshot, .. } = &mut live else {
panic!("current service fixture must be present");
};
let description = snapshot
.config2
.iter_mut()
.find(|value| value.kind() == ServiceConfig2Kind::Description)
.expect("complete default profile");
*description = ServiceConfig2Snapshot::Description(Some("drift".to_owned()));
drifted.complete = live;
assert_ne!(
observe_proxifyre_cutover_scm_state(&mut drifted, &operation, &before)
.expect("drifted fresh-service defaults"),
expected_proxifyre_cutover_scm_effect(&operation).expect("expected create effect")
);
}
fn complete_service(
snapshot: ServiceRestoreSnapshot,
current_dacl_matches: bool,
) -> CompleteServiceObservation {
CompleteServiceObservation::Present {
snapshot: Box::new(snapshot),
current_dacl_matches,
}
}
fn satisfying_scm_observation(
operation: &CutoverOperation,
before: &ServiceRestoreSnapshot,
current_base: &ServiceBaseConfigSnapshot,
) -> CompleteServiceObservation {
if matches!(
operation,
CutoverOperation::DeleteLegacyService | CutoverOperation::DeleteCurrentService
) {
return CompleteServiceObservation::Missing;
}
let current = matches!(
operation,
CutoverOperation::CreateCurrentService
| CutoverOperation::SetCurrentServicePolicy(_)
| CutoverOperation::SetCurrentServiceSecurity
| CutoverOperation::StartCurrentService
| CutoverOperation::StopCurrentService
);
let mut snapshot = before.clone();
let mut current_dacl_matches = false;
if current {
snapshot.base = current_base.clone();
snapshot.config2 = SERVICE_CONFIG2_KINDS
.iter()
.copied()
.map(expected_current_proxifyre_service_policy)
.collect();
current_dacl_matches = matches!(
operation,
CutoverOperation::SetCurrentServiceSecurity
| CutoverOperation::StartCurrentService
| CutoverOperation::StopCurrentService
);
}
snapshot.original_state = if matches!(
operation,
CutoverOperation::StartCurrentService | CutoverOperation::StartLegacyService
) {
ServiceStableState::Running
} else {
ServiceStableState::Stopped
};
complete_service(snapshot, current_dacl_matches)
}
#[cfg(windows)]
#[test]
fn missing_primary_service_config2_probe_is_live_and_read_only() {
let service = crate::process::query_known_service(KnownWindowsService::Proxifyre)
.expect("read-only SCM probe");
if service.exists {
eprintln!("skipping missing-service assertion because ProxiFyreService exists");
return;
}
let executable = std::env::current_exe().expect("current test executable");
assert_eq!(
query_service_config2_exact(
PROXIFYRE_MANAGED_SERVICE_NAME,
&executable,
ServiceConfig2Kind::Description,
)
.expect("missing service query"),
None
);
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CandidateCall {
CreateRoot,
WritePackage(PathBuf),
WriteConfig,
WriteMarker,
WriteReceipt,
}
struct FakeCandidateWriter {
calls: Vec<CandidateCall>,
fail_on: Option<CandidateCall>,
fail_after_effect: Option<CandidateCall>,
observation: ProxifyreCutoverCandidateObservation,
}
impl Default for FakeCandidateWriter {
fn default() -> Self {
Self {
calls: Vec::new(),
fail_on: None,
fail_after_effect: None,
observation: ProxifyreCutoverCandidateObservation::Absent,
}
}
}
impl FakeCandidateWriter {
fn record(&mut self, call: CandidateCall) -> Result<(), ProxifyreNativeHostError> {
self.calls.push(call.clone());
if self.fail_on.as_ref() == Some(&call) {
Err(ProxifyreNativeHostError)
} else if self.fail_after_effect.as_ref() == Some(&call) {
self.observation = expected_candidate_observation();
Err(ProxifyreNativeHostError)
} else {
Ok(())
}
}
}
impl ProxifyreCutoverCandidateWriter for FakeCandidateWriter {
fn observe_candidate(
&mut self,
operation: &CutoverOperation,
) -> Result<ProxifyreCutoverCandidateObservation, ProxifyreNativeHostError> {
if !matches!(
operation,
CutoverOperation::CreateCurrentCandidateRoot
| CutoverOperation::WriteCurrentCandidatePackageEntry(_)
| CutoverOperation::WriteCurrentCandidateConfig
| CutoverOperation::WriteCurrentCandidateMarker
| CutoverOperation::WriteCurrentCandidateReceipt
) {
return Err(ProxifyreNativeHostError);
}
Ok(self.observation.clone())
}
fn create_candidate_root(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::CreateRoot)
}
fn write_candidate_package_entry(
&mut self,
relative_path: &Path,
) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::WritePackage(relative_path.to_path_buf()))
}
fn write_candidate_config(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::WriteConfig)
}
fn write_candidate_marker(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::WriteMarker)
}
fn write_candidate_receipt(&mut self) -> Result<(), ProxifyreNativeHostError> {
self.record(CandidateCall::WriteReceipt)
}
}
fn expected_candidate_observation() -> ProxifyreCutoverCandidateObservation {
let snapshot: SealedPathSnapshot = serde_json::from_value(serde_json::json!({
"identity": {
"volumeSerialNumber": 7,
"fileId": 11,
"kind": "regular_file",
"size": 3
},
"security": {
"selfRelative": [1, 2, 3],
"sacl": "present"
}
}))
.expect("sealed candidate fixture");
ProxifyreCutoverCandidateObservation::Expected(snapshot)
}
#[test]
fn candidate_dispatch_selects_exactly_one_create_new_mutation() {
let mut writer = FakeCandidateWriter::default();
let relative_path = PathBuf::from("ProxiFyre.exe");
assert!(mutate_proxifyre_cutover_candidate(
&mut writer,
&CutoverOperation::WriteCurrentCandidatePackageEntry(relative_path.clone()),
)
.expect("candidate mutation dispatch"));
assert_eq!(
writer.calls,
vec![CandidateCall::WritePackage(relative_path)]
);
}
#[test]
fn candidate_collision_or_write_failure_is_not_hidden() {
let mut writer = FakeCandidateWriter {
fail_on: Some(CandidateCall::WriteReceipt),
..FakeCandidateWriter::default()
};
mutate_proxifyre_cutover_candidate(
&mut writer,
&CutoverOperation::WriteCurrentCandidateReceipt,
)
.expect_err("collision/failure must surface");
assert_eq!(writer.calls, vec![CandidateCall::WriteReceipt]);
}
#[test]
fn candidate_failed_create_or_write_distinguishes_no_effect_from_reacquired_exact_effect() {
for (operation, call) in [
(
CutoverOperation::CreateCurrentCandidateRoot,
CandidateCall::CreateRoot,
),
(
CutoverOperation::WriteCurrentCandidateReceipt,
CandidateCall::WriteReceipt,
),
] {
let mut before_effect = FakeCandidateWriter {
fail_on: Some(call.clone()),
..FakeCandidateWriter::default()
};
mutate_proxifyre_cutover_candidate(&mut before_effect, &operation)
.expect_err("failure before external effect");
assert_eq!(
before_effect
.observe_candidate(&operation)
.expect("observe absent target"),
ProxifyreCutoverCandidateObservation::Absent
);
let mut after_effect = FakeCandidateWriter {
fail_after_effect: Some(call),
..FakeCandidateWriter::default()
};
mutate_proxifyre_cutover_candidate(&mut after_effect, &operation)
.expect_err("failure after external effect");
let observed = after_effect
.observe_candidate(&operation)
.expect("reacquire exact target");
assert!(matches!(
observed,
ProxifyreCutoverCandidateObservation::Expected(SealedPathSnapshot {
identity: safe_fs::StableObjectIdentity {
volume_serial_number: 7,
file_id: 11,
..
},
..
})
));
}
}
#[test]
fn candidate_observer_keeps_unknown_distinct_from_absent_and_expected() {
let operation = CutoverOperation::CreateCurrentCandidateRoot;
let mut writer = FakeCandidateWriter {
observation: ProxifyreCutoverCandidateObservation::Unknown,
..FakeCandidateWriter::default()
};
assert_eq!(
writer
.observe_candidate(&operation)
.expect("typed unknown observation"),
ProxifyreCutoverCandidateObservation::Unknown
);
assert_ne!(
ProxifyreCutoverCandidateObservation::Unknown,
ProxifyreCutoverCandidateObservation::Absent
);
assert_ne!(
ProxifyreCutoverCandidateObservation::Unknown,
expected_candidate_observation()
);
}
#[test]
fn candidate_dispatch_does_not_claim_scm_or_legacy_filesystem_operations() {
let mut writer = FakeCandidateWriter::default();
assert!(!mutate_proxifyre_cutover_candidate(
&mut writer,
&CutoverOperation::CreateCurrentService,
)
.expect("non-candidate operation"));
assert!(writer.calls.is_empty());
}
#[test]
fn partial_candidate_handoff_accepts_only_unique_durable_forward_identity() {
let operation = CutoverOperation::CreateCurrentCandidateRoot;
let identity = safe_fs::StableObjectIdentity {
volume_serial_number: 7,
file_id: 11,
kind: safe_fs::StableObjectKind::Directory,
size: 0,
};
let fingerprint = StateFingerprint::digest("candidate-handoff-test", b"state");
let durable = MutationRecord {
sequence: 0,
direction: MutationDirection::Forward,
operation: operation.clone(),
before_state: fingerprint.clone(),
expected_effect: fingerprint.clone(),
intent_written_at_epoch_seconds: 1,
authority_evidence: None,
effect: Some(MutationEffect {
disposition: EffectDisposition::ExpectedEffect,
observed: fingerprint,
object_identity: Some(identity.clone()),
observed_at_epoch_seconds: 2,
}),
};
assert_eq!(
unique_forward_expected_effect_identity(std::slice::from_ref(&durable), &operation)
.expect("unique durable identity"),
Some(&identity)
);
let mut pending = durable.clone();
pending.effect = None;
assert_eq!(
unique_forward_expected_effect_identity(&[pending], &operation)
.expect("pending intent is not durable effect"),
None
);
let mut missing_identity = durable.clone();
missing_identity.effect.as_mut().unwrap().object_identity = None;
assert!(unique_forward_expected_effect_identity(&[missing_identity], &operation).is_err());
assert!(
unique_forward_expected_effect_identity(&[durable.clone(), durable], &operation).is_err()
);
}
#[test]
fn prepared_candidate_freezes_complete_sorted_final_metadata() {
let (plan, runtime, config, config_sha256) = candidate_inputs();
let prepared = prepare_proxifyre_cutover_candidate(
&plan,
runtime,
&config,
&config_sha256,
false,
1_700_000_000,
)
.expect("prepare complete cutover candidate");
assert_eq!(
prepared.snapshot().files.len(),
CURRENT_PROXIFYRE_PACKAGE_FILES.len() + 3
);
assert!(valid_sha256(&prepared.snapshot().manifest_fingerprint));
assert!(prepared.snapshot().files.windows(2).all(|pair| {
candidate_relative_label(&pair[0].relative_path)
< candidate_relative_label(&pair[1].relative_path)
}));
let config_spec = prepared
.file_spec(Path::new("app-config.json"))
.expect("config spec");
assert_eq!(config_spec.role, CurrentCandidateFileRole::Config);
assert_eq!(config_spec.sha256, config_sha256);
let marker: SystemProxifyreMarker = serde_json::from_slice(
prepared
.file_bytes(Path::new(PROXIFYRE_MARKER_FILE))
.expect("marker bytes"),
)
.expect("marker JSON");
assert!(marker.packet_filter_installed_by_proxy_warden);
let receipt: InstallReceipt = serde_json::from_slice(
prepared
.file_bytes(Path::new(INSTALL_RECEIPT_FILENAME))
.expect("receipt bytes"),
)
.expect("receipt JSON");
assert_eq!(receipt.installed_at, 1_700_000_000);
assert!(receipt
.windows_packet_filter
.as_ref()
.is_some_and(|ownership| ownership.installed_by_proxy_warden));
let (_, repeated_runtime, _, _) = candidate_inputs_with_plan(&plan);
let repeated = prepare_proxifyre_cutover_candidate(
&plan,
repeated_runtime,
&config,
&config_sha256,
false,
1_700_000_000,
)
.expect("repeat identical candidate");
assert_eq!(
prepared.snapshot().manifest_fingerprint,
repeated.snapshot().manifest_fingerprint
);
}
#[test]
fn captured_timestamp_and_preexisting_packet_filter_change_final_manifest() {
let (plan, runtime, config, config_sha256) = candidate_inputs();
let first = prepare_proxifyre_cutover_candidate(
&plan,
runtime,
&config,
&config_sha256,
false,
1_700_000_000,
)
.expect("first candidate");
let (_, runtime, _, _) = candidate_inputs_with_plan(&plan);
let second = prepare_proxifyre_cutover_candidate(
&plan,
runtime,
&config,
&config_sha256,
true,
1_700_000_001,
)
.expect("second candidate");
assert_ne!(
first.snapshot().manifest_fingerprint,
second.snapshot().manifest_fingerprint
);
let receipt: InstallReceipt = serde_json::from_slice(
second
.file_bytes(Path::new(INSTALL_RECEIPT_FILENAME))
.expect("receipt bytes"),
)
.expect("receipt JSON");
assert!(receipt.windows_packet_filter.is_none());
}
fn candidate_inputs() -> (
ProxifyreCutoverPlan,
PreparedProxifyreRuntime,
Vec<u8>,
String,
) {
let app_root = std::env::temp_dir().join("proxywarden-cutover-contract");
let config = br#"{"proxies":[],"applications":[]}"#.to_vec();
let config_sha256 = format!("{:x}", Sha256::digest(&config));
let package_sha256 = "a".repeat(64);
let plan = ProxifyreCutoverPlan::new(
&app_root,
PathBuf::from(r"C:\Tools\ProxiFyre"),
LegacyServiceState::Stopped,
"2.2.1".to_owned(),
package_sha256,
config_sha256.clone(),
"b".repeat(64),
uuid::Uuid::new_v4().hyphenated().to_string(),
);
let (_, runtime, _, _) = candidate_inputs_with_plan(&plan);
(plan, runtime, config, config_sha256)
}
fn candidate_inputs_with_plan(
plan: &ProxifyreCutoverPlan,
) -> (
ProxifyreCutoverPlan,
PreparedProxifyreRuntime,
Vec<u8>,
String,
) {
let files: Vec<_> = CURRENT_PROXIFYRE_PACKAGE_FILES
.iter()
.enumerate()
.map(|(index, name)| {
let bytes = vec![u8::try_from(index + 1).expect("small fixture index")];
ProxifyreStagedFile {
relative_path: (*name).to_owned(),
sha256: format!("{:x}", Sha256::digest(&bytes)),
size: bytes.len() as u64,
bytes,
}
})
.collect();
let runtime = PreparedProxifyreRuntime {
proof: PrivilegedPackageProof {
component_id: ComponentId::Proxifyre,
version: plan.bundled_version.clone(),
asset_name: "proxifyre.zip".to_owned(),
sha256: plan.package_fingerprint.clone(),
size: 123,
source: PackageSource::Bundled,
independent_proof: None,
},
installed_files: installed_file_inventory(&files),
files,
};
let config = br#"{"proxies":[],"applications":[]}"#.to_vec();
let config_sha256 = format!("{:x}", Sha256::digest(&config));
(plan.clone(), runtime, config, config_sha256)
}
File diff suppressed because it is too large Load Diff
-687
View File
@@ -1,687 +0,0 @@
//! Static-template PowerShell generation for explicit ProxiFyre package actions.
use crate::component_detection::{default_proxifyre_install_dir, DetectedProxyfier};
use crate::powershell::escape_single as escape_powershell_single;
use crate::proxifyre_ownership::ManagedProxiFyreOwnership;
use std::path::Path;
const PROXIFYRE_RELEASE_API_URL: &str =
"https://api.github.com/repos/wiresock/proxifyre/releases/latest";
const NDISAPI_RELEASE_API_URL: &str =
"https://api.github.com/repos/wiresock/ndisapi/releases/latest";
const PROXIFYRE_PINNED_RELEASE_TAG: &str = "v2.2.1";
const NDISAPI_PINNED_RELEASE_TAG: &str = "v3.6.2";
const NDISAPI_PINNED_INSTALLER_VERSION: &str = "3.6.2.1";
const VC_REDIST_X64_URL: &str = "https://aka.ms/vc14/vc_redist.x64.exe";
const VC_REDIST_X86_URL: &str = "https://aka.ms/vc14/vc_redist.x86.exe";
pub const PROXIFYRE_FIREWALL_INBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Inbound";
pub const PROXIFYRE_FIREWALL_OUTBOUND_RULE: &str = "ProxyWarden.ProxiFyre.Outbound";
pub fn install_proxifyre_script(generated_config_path: &Path) -> String {
install_proxifyre_script_with_bundle(generated_config_path, None)
}
pub fn install_proxifyre_script_with_bundle(
generated_config_path: &Path,
bundled_asset_dir: Option<&Path>,
) -> String {
install_proxifyre_script_for_target(
generated_config_path,
bundled_asset_dir,
&default_proxifyre_install_dir(),
)
}
pub fn install_proxifyre_script_for_target(
generated_config_path: &Path,
bundled_asset_dir: Option<&Path>,
target_dir: &Path,
) -> String {
let mut script = String::new();
script.push_str(&format!(
"$targetDir = '{}'\n",
escape_powershell_single(&target_dir.display().to_string())
));
script.push_str(&format!(
"$generatedConfigPath = '{}'\n",
escape_powershell_single(&generated_config_path.display().to_string())
));
script.push_str(&format!(
"$bundledAssetDir = '{}'\n",
escape_powershell_single(
&bundled_asset_dir
.map(|path| path.display().to_string())
.unwrap_or_default()
)
));
script.push_str("$script:bundledAssetDir = [string]$bundledAssetDir\n");
script.push_str(&format!(
"$proxifyreReleaseApi = '{}'\n",
escape_powershell_single(PROXIFYRE_RELEASE_API_URL)
));
script.push_str(&format!(
"$ndisapiReleaseApi = '{}'\n",
escape_powershell_single(NDISAPI_RELEASE_API_URL)
));
script.push_str(&format!(
"$proxifyrePinnedReleaseTag = '{}'\n",
escape_powershell_single(PROXIFYRE_PINNED_RELEASE_TAG)
));
script.push_str(&format!(
"$ndisapiPinnedReleaseTag = '{}'\n",
escape_powershell_single(NDISAPI_PINNED_RELEASE_TAG)
));
script.push_str(&format!(
"$ndisapiPinnedInstallerVersion = '{}'\n",
escape_powershell_single(NDISAPI_PINNED_INSTALLER_VERSION)
));
script.push_str(&format!(
"$vcRedistX64Url = '{}'\n",
escape_powershell_single(VC_REDIST_X64_URL)
));
script.push_str(&format!(
"$vcRedistX86Url = '{}'\n",
escape_powershell_single(VC_REDIST_X86_URL)
));
script.push_str(
r#"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function Get-NativeArchitecture {
$processor = Get-CimInstance Win32_Processor | Select-Object -First 1
if ($null -ne $processor -and $processor.Architecture -eq 12) { return 'ARM64' }
if ([Environment]::Is64BitOperatingSystem) { return 'x64' }
return 'x86'
}
function Get-SafeUriForLog([string]$uri) {
try {
$parsed = [Uri]$uri
$port = if ($parsed.IsDefaultPort) { '' } else { ":$($parsed.Port)" }
return "$($parsed.Scheme)://$($parsed.Host)$port$($parsed.AbsolutePath)"
} catch {
return '<invalid-url>'
}
}
function Invoke-ReleaseApi([string]$uri, [string]$label) {
$safeUri = Get-SafeUriForLog $uri
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/vnd.github+json' }
$lastError = $null
foreach ($attempt in 1..3) {
try {
return Invoke-RestMethod -Uri $uri -Headers $headers -TimeoutSec 60 -MaximumRedirection 10
} catch {
$lastError = $_.Exception.Message
if ($attempt -lt 3) {
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
}
}
}
throw "Не удалось получить metadata для $label ($safeUri): $lastError"
}
function New-ReleaseAsset([string]$name, [string]$url) {
[PSCustomObject]@{
name = $name
browser_download_url = $url
digest = $null
}
}
function Resolve-ReleaseAsset([string]$apiUri, [string]$pattern, [string]$label, $fallbackAsset, [int]$fallbackPercent) {
try {
$release = Invoke-ReleaseApi $apiUri $label
return Select-Asset $release.assets $pattern $label
} catch {
$fallbackUri = Get-SafeUriForLog $fallbackAsset.browser_download_url
Write-ProxyWardenProgress $script:progressOperation $script:progressActiveStep 'running' $fallbackPercent "GitHub API недоступен для $label. Пробую прямую ссылку: $fallbackUri"
return $fallbackAsset
}
}
function Get-PinnedProxiFyreAsset([string]$arch) {
$archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' }
$name = "ProxiFyre-$proxifyrePinnedReleaseTag-$archLabel-signed.zip"
$url = "https://github.com/wiresock/proxifyre/releases/download/$proxifyrePinnedReleaseTag/$name"
return New-ReleaseAsset $name $url
}
function Get-PinnedWindowsPacketFilterAsset([string]$arch) {
$archLabel = if ($arch -eq 'ARM64') { 'ARM64' } elseif ($arch -eq 'x86') { 'x86' } else { 'x64' }
$name = "Windows.Packet.Filter.$ndisapiPinnedInstallerVersion.$archLabel.msi"
$url = "https://github.com/wiresock/ndisapi/releases/download/$ndisapiPinnedReleaseTag/$name"
return New-ReleaseAsset $name $url
}
function Complete-Download([string]$partialPath, [string]$path, [string]$label) {
if (-not (Test-Path -LiteralPath $partialPath)) {
throw "${label}: файл не был создан."
}
$item = Get-Item -LiteralPath $partialPath
if ($item.Length -le 0) {
throw "${label}: скачанный файл пустой."
}
Move-Item -LiteralPath $partialPath -Destination $path -Force
}
function Invoke-WebClientDownload([string]$uri, [string]$partialPath) {
$client = New-Object System.Net.WebClient
try {
$client.Headers.Add('User-Agent', 'proxywarden')
$client.Headers.Add('Accept', 'application/octet-stream,*/*')
$client.DownloadFile($uri, $partialPath)
} finally {
$client.Dispose()
}
}
function Invoke-CurlDownload([string]$uri, [string]$partialPath) {
$curl = Get-Command 'curl.exe' -ErrorAction SilentlyContinue
if ($null -eq $curl) {
throw 'curl.exe не найден.'
}
$curlOutput = & $curl.Source --silent --show-error --fail --location --retry 2 --retry-delay 2 --connect-timeout 30 --max-time 180 --user-agent 'proxywarden' --output $partialPath --url $uri 2>&1
if ($LASTEXITCODE -ne 0) {
$curlMessage = ($curlOutput | Out-String).Trim()
if ([string]::IsNullOrWhiteSpace($curlMessage)) {
throw "curl.exe завершился с кодом $LASTEXITCODE."
}
throw "curl.exe завершился с кодом ${LASTEXITCODE}: $curlMessage"
}
}
function Invoke-Download([string]$uri, [string]$path, [string]$label) {
$safeUri = Get-SafeUriForLog $uri
$partialPath = "$path.part"
$headers = @{ 'User-Agent' = 'proxywarden'; 'Accept' = 'application/octet-stream,*/*' }
$webRequestError = $null
$webClientError = $null
$curlError = $null
foreach ($attempt in 1..3) {
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
try {
Invoke-WebRequest -UseBasicParsing -Uri $uri -OutFile $partialPath -Headers $headers -TimeoutSec 180 -MaximumRedirection 10
Complete-Download $partialPath $path $label
return
} catch {
$webRequestError = $_.Exception.Message
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
if ($attempt -lt 3) {
Start-Sleep -Seconds ([Math]::Min(10, $attempt * 2))
}
}
}
try {
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
Invoke-WebClientDownload $uri $partialPath
Complete-Download $partialPath $path $label
return
} catch {
$webClientError = $_.Exception.Message
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
}
try {
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
Invoke-CurlDownload $uri $partialPath
Complete-Download $partialPath $path $label
return
} catch {
$curlError = $_.Exception.Message
Remove-Item -LiteralPath $partialPath -Force -ErrorAction SilentlyContinue
}
$errors = @()
if (-not [string]::IsNullOrWhiteSpace($webRequestError)) { $errors += "Invoke-WebRequest: $webRequestError" }
if (-not [string]::IsNullOrWhiteSpace($webClientError)) { $errors += "WebClient: $webClientError" }
if (-not [string]::IsNullOrWhiteSpace($curlError)) { $errors += "curl.exe: $curlError" }
$details = if ($errors.Count -gt 0) { $errors -join ' | ' } else { 'неизвестная ошибка' }
throw "Не удалось скачать $label ($safeUri): $details"
}
function Select-Asset($assets, [string]$pattern, [string]$label) {
$asset = $assets | Where-Object { $_.name -match $pattern } | Select-Object -First 1
if ($null -eq $asset) { throw "Не найден подходящий asset для $label ($pattern)." }
return $asset
}
function Verify-AssetHash([string]$path, $asset) {
if ($asset.digest -match '^sha256:(.+)$') {
$expected = $Matches[1].ToLowerInvariant()
$actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected) {
throw "SHA256 не совпал для $($asset.name). Ожидалось $expected, получилось $actual."
}
}
}
function Assert-ExitCode($process, [string]$label) {
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) {
throw "$label завершился с кодом $($process.ExitCode)."
}
}
function Get-InstalledProgram([string]$pattern) {
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -match $pattern } |
Select-Object -First 1
}
function Test-VcRuntime([string]$arch) {
$pattern = if ($arch -eq 'ARM64') {
'Microsoft Visual C\+\+.*Redistributable.*\((ARM64|x64)\)'
} else {
"Microsoft Visual C\+\+.*Redistributable.*\($arch\)"
}
return $null -ne (Get-InstalledProgram $pattern)
}
function Test-WindowsPacketFilter {
return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI')
}
function Get-LogTail([string]$path) {
if (-not (Test-Path -LiteralPath $path)) { return '' }
return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' '
}
function Get-BundledAssetDir {
$dir = [string]$script:bundledAssetDir
if ([string]::IsNullOrWhiteSpace($dir)) { return $null }
if (-not (Test-Path -LiteralPath $dir -PathType Container)) { return $null }
return $dir
}
function Get-BundledAssetManifest {
$assetDir = Get-BundledAssetDir
if ($null -eq $assetDir) { return $null }
$manifestPath = [IO.Path]::Combine($assetDir, 'manifest.json')
if (-not (Test-Path -LiteralPath $manifestPath)) { return $null }
try {
return Get-Content -LiteralPath $manifestPath -Raw -Encoding UTF8 | ConvertFrom-Json
} catch {
throw "Не удалось прочитать manifest встроенных пакетов ProxiFyre: $($_.Exception.Message)"
}
}
$script:bundledAssetManifest = Get-BundledAssetManifest
function Get-BundledAssetHash([string]$name) {
if ($null -eq $script:bundledAssetManifest -or $null -eq $script:bundledAssetManifest.files) {
return $null
}
$entry = $script:bundledAssetManifest.files |
Where-Object { $_.name -eq $name } |
Select-Object -First 1
if ($null -eq $entry) { return $null }
return [string]$entry.sha256
}
function Verify-BundledAssetHash([string]$path, [string]$label) {
$name = [IO.Path]::GetFileName($path)
$expected = Get-BundledAssetHash $name
if ([string]::IsNullOrWhiteSpace($expected)) {
throw "Во встроенном manifest нет SHA256 для $label ($name)."
}
$actual = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $expected.ToLowerInvariant()) {
throw "SHA256 не совпал для встроенного $label ($name). Ожидалось $expected, получилось $actual."
}
}
function Get-BundledAsset([string]$pattern, [string]$label) {
$assetDir = Get-BundledAssetDir
if ($null -eq $assetDir) { return $null }
$asset = Get-ChildItem -LiteralPath $assetDir -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match $pattern } |
Select-Object -First 1
if ($null -eq $asset) { return $null }
Verify-BundledAssetHash $asset.FullName $label
return $asset.FullName
}
function Copy-BundledAsset([string]$sourcePath, [string]$targetPath, [string]$label) {
Copy-Item -LiteralPath $sourcePath -Destination $targetPath -Force
$item = Get-Item -LiteralPath $targetPath
if ($item.Length -le 0) {
throw "${label}: встроенный файл пустой."
}
}
$arch = Get-NativeArchitecture
$workDir = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-proxifyre-install'
$extractDir = Join-Path $workDir 'proxifyre'
Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $workDir, $extractDir, $targetDir | Out-Null
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 8 'Проверяю сетевой драйвер Windows Packet Filter.'
$packetFilterAlreadyInstalled = Test-WindowsPacketFilter
if (-not $packetFilterAlreadyInstalled) {
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 14 'Готовлю Windows Packet Filter.'
$ndisPattern = if ($arch -eq 'ARM64') { 'ARM64\.msi$' } elseif ($arch -eq 'x86') { 'x86\.msi$' } else { 'x64\.msi$' }
$bundledNdisPath = Get-BundledAsset $ndisPattern 'Windows Packet Filter'
if ($null -ne $bundledNdisPath) {
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Использую встроенный Windows Packet Filter.'
$ndisPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledNdisPath))
Copy-BundledAsset $bundledNdisPath $ndisPath 'Windows Packet Filter'
} else {
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 16 'Скачиваю Windows Packet Filter.'
$ndisAsset = Resolve-ReleaseAsset $ndisapiReleaseApi $ndisPattern 'Windows Packet Filter' (Get-PinnedWindowsPacketFilterAsset $arch) 16
$ndisPath = Join-Path $workDir $ndisAsset.name
Invoke-Download $ndisAsset.browser_download_url $ndisPath 'Windows Packet Filter'
Verify-AssetHash $ndisPath $ndisAsset
}
$ndisLogPath = Join-Path $workDir 'windows-packet-filter-install.log'
Write-ProxyWardenProgress 'install' 'packet-filter' 'running' 26 'Устанавливаю Windows Packet Filter.'
$ndisProcess = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $ndisPath, '/qn', '/norestart', '/L*v', $ndisLogPath) -Wait -PassThru -WindowStyle Hidden
if ($ndisProcess.ExitCode -ne 0 -and $ndisProcess.ExitCode -ne 3010 -and -not (Test-WindowsPacketFilter)) {
$ndisLogTail = Get-LogTail $ndisLogPath
throw "Windows Packet Filter завершился с кодом $($ndisProcess.ExitCode). MSI log: $ndisLogPath $ndisLogTail"
}
}
Write-ProxyWardenProgress 'install' 'packet-filter' 'succeeded' 36 'Сетевой драйвер готов.'
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 40 'Проверяю Microsoft Visual C++ Runtime.'
if (-not (Test-VcRuntime $arch)) {
$vcBundledPattern = if ($arch -eq 'x86') { '^vc_redist\.x86\.exe$' } else { '^vc_redist\.x64\.exe$' }
$vcRedistUrl = if ($arch -eq 'x86') { $vcRedistX86Url } else { $vcRedistX64Url }
$bundledVcPath = Get-BundledAsset $vcBundledPattern 'Microsoft Visual C++ Runtime'
$vcRedistPath = Join-Path $workDir 'vc_redist.exe'
if ($null -ne $bundledVcPath) {
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Использую встроенный Microsoft Visual C++ Runtime.'
Copy-BundledAsset $bundledVcPath $vcRedistPath 'Microsoft Visual C++ Runtime'
} else {
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 46 'Скачиваю Microsoft Visual C++ Runtime.'
Invoke-Download $vcRedistUrl $vcRedistPath 'Microsoft Visual C++ Runtime'
}
Write-ProxyWardenProgress 'install' 'vc-runtime' 'running' 54 'Устанавливаю Microsoft Visual C++ Runtime.'
$vcProcess = Start-Process -FilePath $vcRedistPath -ArgumentList @('/install', '/quiet', '/norestart') -Wait -PassThru -WindowStyle Hidden
if ($vcProcess.ExitCode -ne 0 -and $vcProcess.ExitCode -ne 3010 -and $vcProcess.ExitCode -ne 1638 -and -not (Test-VcRuntime $arch)) {
throw "Visual C++ Runtime завершился с кодом $($vcProcess.ExitCode)."
}
}
Write-ProxyWardenProgress 'install' 'vc-runtime' 'succeeded' 62 'Среда запуска готова.'
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 66 'Готовлю ProxiFyre.'
$proxifyrePattern = if ($arch -eq 'ARM64') { 'ARM64-signed\.zip$' } elseif ($arch -eq 'x86') { 'x86-signed\.zip$' } else { 'x64-signed\.zip$' }
$bundledProxiFyrePath = Get-BundledAsset $proxifyrePattern 'ProxiFyre'
if ($null -ne $bundledProxiFyrePath) {
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Использую встроенный ProxiFyre.'
$proxifyreZipPath = Join-Path $workDir ([IO.Path]::GetFileName($bundledProxiFyrePath))
Copy-BundledAsset $bundledProxiFyrePath $proxifyreZipPath 'ProxiFyre'
} else {
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 68 'Скачиваю ProxiFyre.'
$proxifyreAsset = Resolve-ReleaseAsset $proxifyreReleaseApi $proxifyrePattern 'ProxiFyre' (Get-PinnedProxiFyreAsset $arch) 68
$proxifyreZipPath = Join-Path $workDir $proxifyreAsset.name
Invoke-Download $proxifyreAsset.browser_download_url $proxifyreZipPath 'ProxiFyre'
Verify-AssetHash $proxifyreZipPath $proxifyreAsset
}
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 76 'Распаковываю ProxiFyre.'
Expand-Archive -LiteralPath $proxifyreZipPath -DestinationPath $extractDir -Force
$proxifyreExe = Get-ChildItem -LiteralPath $extractDir -Recurse -Filter 'ProxiFyre.exe' | Select-Object -First 1
if ($null -eq $proxifyreExe) { throw 'В архиве ProxiFyre не найден ProxiFyre.exe.' }
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 82 'Копирую ProxiFyre в папку установки.'
Copy-Item -Path (Join-Path $proxifyreExe.Directory.FullName '*') -Destination $targetDir -Recurse -Force
$configTarget = Join-Path $targetDir 'app-config.json'
if (Test-Path -LiteralPath $generatedConfigPath) {
Copy-Item -LiteralPath $generatedConfigPath -Destination $configTarget -Force
} elseif (-not (Test-Path -LiteralPath $configTarget)) {
$emptyConfig = '{"logLevel":"Info","bypassLan":true,"proxies":[]}'
Set-Content -LiteralPath $configTarget -Value $emptyConfig -Encoding UTF8
}
$markerPath = Join-Path $targetDir 'proxywarden-component.json'
$markerJson = [ordered]@{
manager = 'ProxyWarden'
component = 'proxifyre'
serviceName = 'ProxiFyreService'
installedAt = (Get-Date).ToString('o')
installRoot = $targetDir
packetFilterInstalledByProxyWarden = (-not $packetFilterAlreadyInstalled)
} | ConvertTo-Json -Depth 4
[IO.File]::WriteAllText($markerPath, $markerJson, [Text.UTF8Encoding]::new($false))
Write-ProxyWardenProgress 'install' 'proxifyre' 'running' 90 'Устанавливаю и запускаю службу ProxiFyre.'
Push-Location $targetDir
try {
& .\ProxiFyre.exe stop | Out-Null
& .\ProxiFyre.exe uninstall | Out-Null
& .\ProxiFyre.exe install
if ($LASTEXITCODE -ne 0) { throw "ProxiFyre.exe install завершился с кодом $LASTEXITCODE." }
& .\ProxiFyre.exe start
if ($LASTEXITCODE -ne 0) {
Start-Service -Name 'ProxiFyreService' -ErrorAction Stop
}
} finally {
Pop-Location
}
Write-ProxyWardenProgress 'install' 'proxifyre' 'succeeded' 100 'ProxiFyre и сетевой драйвер готовы.'
"#,
);
script
}
pub fn configure_proxifyre_firewall_script(executable_path: &Path) -> String {
let executable_path = escape_powershell_single(&executable_path.display().to_string());
format!(
r#"
$exePath = '{executable_path}'
if (-not (Test-Path -LiteralPath $exePath -PathType Leaf)) {{
throw "ProxiFyre.exe не найден по подтвержденному пути: $exePath"
}}
$ruleSpecs = @(
@{{ Name = '{PROXIFYRE_FIREWALL_INBOUND_RULE}'; DisplayName = 'ProxyWarden: ProxiFyre (входящие)'; Direction = 'Inbound' }},
@{{ Name = '{PROXIFYRE_FIREWALL_OUTBOUND_RULE}'; DisplayName = 'ProxyWarden: ProxiFyre (исходящие)'; Direction = 'Outbound' }}
)
foreach ($rule in $ruleSpecs) {{
Get-NetFirewallRule -Name $rule.Name -ErrorAction SilentlyContinue |
Remove-NetFirewallRule -ErrorAction Stop
New-NetFirewallRule `
-Name $rule.Name `
-DisplayName $rule.DisplayName `
-Group 'ProxyWarden' `
-Program $exePath `
-Direction $rule.Direction `
-Action Allow `
-Profile Any `
-Enabled True `
-ErrorAction Stop | Out-Null
}}
"#,
)
}
pub fn uninstall_proxifyre_script(
detected: Option<&DetectedProxyfier>,
ownership: &ManagedProxiFyreOwnership,
) -> String {
let mut script = String::new();
let install_dir = detected
.map(|detected| detected.install_dir.display().to_string())
.unwrap_or_default();
let executable_path = detected
.map(|detected| detected.executable_path.display().to_string())
.unwrap_or_default();
script.push_str(&format!(
"$installDir = '{}'\n",
escape_powershell_single(&install_dir)
));
script.push_str(&format!(
"$exePath = '{}'\n",
escape_powershell_single(&executable_path)
));
script.push_str(&format!(
"$serviceName = '{}'\n",
escape_powershell_single(&ownership.service_name)
));
script.push_str(&format!(
"$removePacketFilter = ${}\n",
if ownership.remove_packet_filter {
"true"
} else {
"false"
}
));
script.push_str(
r#"
function Get-InstalledProgram([string]$pattern) {
$paths = @(
'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
return Get-ItemProperty -Path $paths -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -match $pattern } |
Select-Object -First 1 DisplayName, DisplayVersion, PSChildName, UninstallString, QuietUninstallString
}
function Test-WindowsPacketFilter {
return $null -ne (Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI')
}
function Get-LogTail([string]$path) {
if (-not (Test-Path -LiteralPath $path)) { return '' }
return (Get-Content -LiteralPath $path -Tail 40 -ErrorAction SilentlyContinue) -join ' '
}
function Resolve-MsiProductCode($program, [string]$label) {
if ($null -eq $program) { return $null }
if ($program.PSChildName -match '^\{[0-9A-Fa-f-]{36}\}$') {
return $program.PSChildName
}
foreach ($candidate in @($program.QuietUninstallString, $program.UninstallString)) {
if ($candidate -match '\{[0-9A-Fa-f-]{36}\}') {
return $Matches[0]
}
}
throw "Не удалось найти MSI product code для $label. Отказываюсь запускать произвольный UninstallString."
}
function Uninstall-MsiProgram($program, [string]$label, [string]$logPath) {
$productCode = Resolve-MsiProductCode $program $label
if ([string]::IsNullOrWhiteSpace($productCode)) { return }
$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/x', $productCode, '/qn', '/norestart', '/L*v', $logPath) -Wait -PassThru -WindowStyle Hidden
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010 -and $process.ExitCode -ne 1605) {
$logTail = Get-LogTail $logPath
throw "$label uninstall завершился с кодом $($process.ExitCode). MSI log: $logPath $logTail"
}
}
function Get-ServiceBinaryPath([string]$pathName) {
if ([string]::IsNullOrWhiteSpace($pathName)) { return $null }
$pathName = $pathName.Trim()
if ($pathName.StartsWith('"')) {
$closingQuote = $pathName.IndexOf('"', 1)
if ($closingQuote -lt 2) { return $null }
return $pathName.Substring(1, $closingQuote - 1)
}
return ($pathName -split '\s+', 2)[0]
}
function Find-ManagedProxiFyreService {
$escapedName = $serviceName.Replace("'", "''")
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
if ($null -eq $record) { return $null }
$binaryPath = Get-ServiceBinaryPath $record.PathName
if (-not [string]::Equals($binaryPath, $exePath, [StringComparison]::OrdinalIgnoreCase)) { return $null }
return Get-Service -Name $serviceName -ErrorAction SilentlyContinue
}
function Get-ServiceProcessId([string]$name) {
$escapedName = $name.Replace("'", "''")
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
if ($null -eq $record) { return 0 }
return [int]$record.ProcessId
}
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 10 'Останавливаю службу ProxiFyre.'
$service = Find-ManagedProxiFyreService
if ($null -ne $service -and $service.Status -ne 'Stopped') {
try {
if ($service.CanStop) { Stop-Service -Name $service.Name -Force -ErrorAction SilentlyContinue }
$service = Get-Service -Name $service.Name -ErrorAction SilentlyContinue
if ($null -ne $service) { $service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(8)) }
} catch {}
}
$service = Find-ManagedProxiFyreService
if ($null -ne $service -and $service.Status -ne 'Stopped') {
$processId = Get-ServiceProcessId $service.Name
if ($processId -gt 0) {
taskkill.exe /PID $processId /F | Out-Null
Start-Sleep -Milliseconds 700
}
}
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'running' 34 'Удаляю службу и файлы ProxiFyre.'
if (-not [string]::IsNullOrWhiteSpace($exePath) -and (Test-Path -LiteralPath $exePath)) {
Push-Location (Split-Path -Parent $exePath)
try {
& $exePath uninstall | Out-Null
} finally {
Pop-Location
}
}
$service = Find-ManagedProxiFyreService
if ($null -ne $service) {
sc.exe delete $service.Name | Out-Null
}
foreach ($firewallRuleName in @('ProxyWarden.ProxiFyre.Inbound', 'ProxyWarden.ProxiFyre.Outbound')) {
Get-NetFirewallRule -Name $firewallRuleName -ErrorAction SilentlyContinue |
Remove-NetFirewallRule -ErrorAction Stop
}
if (-not [string]::IsNullOrWhiteSpace($installDir) -and (Test-Path -LiteralPath $installDir)) {
Remove-Item -LiteralPath $installDir -Recurse -Force
}
Write-ProxyWardenProgress 'uninstall' 'proxifyre' 'succeeded' 58 'ProxiFyre удален.'
if ($removePacketFilter) {
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 68 'Проверяю Windows Packet Filter.'
$packetFilter = Get-InstalledProgram 'Windows Packet Filter|WinpkFilter|NDISAPI'
if ($null -ne $packetFilter) {
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'running' 78 'Удаляю Windows Packet Filter.'
$driverLogPath = Join-Path ([IO.Path]::GetTempPath()) 'proxywarden-windows-packet-filter-uninstall.log'
Uninstall-MsiProgram $packetFilter 'Windows Packet Filter' $driverLogPath
}
if (Test-WindowsPacketFilter) {
throw 'Windows Packet Filter все еще найден после удаления. Возможно, Windows требует перезагрузку.'
}
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'succeeded' 100 'ProxiFyre и принадлежащий ProxyWarden Windows Packet Filter удалены.'
} else {
Write-ProxyWardenProgress 'uninstall' 'packet-filter' 'skipped' 100 'Windows Packet Filter оставлен: marker не подтверждает владение ProxyWarden.'
}
"#,
);
script
}
+80 -44
View File
@@ -10,10 +10,15 @@ use crate::adapters::proxy_router::{
use crate::clock::Clock;
use crate::command_dto::{ActivityEntryDto, CommandError};
use crate::component_detection::{
detect_proxyfier_install, detect_proxyfier_install_with_host, detect_singbox_install,
DetectedProxyfier, DetectedSingBox, ProxyfierDetectionHost, SystemProxyfierDetectionHost,
detect_proxyfier_install, detect_singbox_install, inventory_proxyfier_with_host,
inventory_proxyfier_with_host_and_current_root, DetectedProxyfier, DetectedSingBox,
ProxyfierDetectionHost, SystemProxyfierDetectionHost,
};
use crate::component_status::components_or_defaults_with_detection;
use crate::component_inventory::{
run_authorized_component_action, AuthorizedActionError, ComponentClassification,
InventoryAction,
};
use crate::component_status::components_with_detection;
use crate::models::{ActivityEntry, ActivityLevel};
use crate::safe_fs;
use crate::storage::JsonStorage;
@@ -58,6 +63,7 @@ pub trait ProxyApplyHelper {
pub struct DetectedProxyApplyHelper<H = SystemProxyfierDetectionHost> {
host: H,
current_root: Option<std::path::PathBuf>,
}
impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
@@ -68,7 +74,19 @@ impl DetectedProxyApplyHelper<SystemProxyfierDetectionHost> {
impl<H> From<H> for DetectedProxyApplyHelper<H> {
fn from(host: H) -> Self {
Self { host }
Self {
host,
current_root: None,
}
}
}
impl<H> DetectedProxyApplyHelper<H> {
pub fn with_current_root(host: H, current_root: std::path::PathBuf) -> Self {
Self {
host,
current_root: Some(current_root),
}
}
}
@@ -80,11 +98,36 @@ where
&self,
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
let Some(detected) = detect_proxyfier_install_with_host(&self.host) else {
let inventory = self.current_root.as_deref().map_or_else(
|| inventory_proxyfier_with_host(&self.host),
|current_root| inventory_proxyfier_with_host_and_current_root(&self.host, current_root),
);
if inventory.classification() == ComponentClassification::Missing {
return staged_apply_result(request);
};
}
if inventory.classification() == ComponentClassification::ManagedLegacy {
return Err(CommandError::new(
"legacy_cutover_required",
"Старая установка ProxiFyre не изменена. Сначала выполните явный перенос компонента.",
));
}
run_authorized_component_action(&inventory, InventoryAction::Apply, |_| {
if inventory.classification() == ComponentClassification::ManagedCurrent {
return staged_managed_current_result(request);
}
Err(CommandError::new(
"ownership_mismatch",
"Найденный ProxiFyre не прошел ownership-проверку.",
))
})
.map_err(authorized_action_error)
}
}
apply_to_detected_proxyfier(request, &detected)
fn authorized_action_error(error: AuthorizedActionError<CommandError>) -> CommandError {
match error {
AuthorizedActionError::Denied(issue) => CommandError::new(issue.code, issue.message),
AuthorizedActionError::Runner(error) => error,
}
}
@@ -112,10 +155,12 @@ pub fn apply_profiles_with_services_and_detection(
detected_proxyfier: Option<DetectedProxyfier>,
detected_singbox: Option<DetectedSingBox>,
) -> Result<ApplyProfilesResponse, CommandError> {
let transaction =
crate::configuration_transaction::ConfigurationTransaction::begin(storage, None)
.map_err(storage_error)?;
let profiles = storage.read_profiles().map_err(storage_error)?;
let targets = storage.read_targets().map_err(storage_error)?;
let components =
components_or_defaults_with_detection(storage, detected_proxyfier, detected_singbox)?;
let components = components_with_detection(detected_proxyfier, detected_singbox);
let generated =
match adapter.generate_config(ProxyRouterRequest::new(&profiles, &targets, &components)) {
Ok(generated) => generated,
@@ -139,10 +184,18 @@ pub fn apply_profiles_with_services_and_detection(
config_contents: generated.contents.as_str(),
})?;
crate::route_state::record_prepared_locked(
storage,
crate::privileged_jobs::ManagedComponent::Proxifyre,
)
.map_err(storage_error)?;
if helper_result.success {
transaction.commit().map_err(storage_error)?;
} else {
drop(transaction);
}
let activity = activity_for_apply(clock, &generated, &generated_path, &helper_result);
storage
.append_activity(activity.clone())
.map_err(storage_error)?;
let _ = storage.append_activity(activity.clone());
Ok(ApplyProfilesResponse {
success: helper_result.success,
@@ -158,38 +211,7 @@ pub fn apply_profiles_with_services_and_detection(
}
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
}
fn apply_to_detected_proxyfier(
request: HelperApplyRequest<'_>,
detected: &DetectedProxyfier,
) -> Result<HelperApplyResult, CommandError> {
let Some(config_path) = &detected.config_path else {
return staged_apply_result(request);
};
safe_fs::write_with_backup(config_path, request.config_contents.as_bytes()).map_err(
|error| {
CommandError::new(
"proxyfier_apply_failed",
format!(
"Не удалось безопасно записать конфиг ProxiFyre '{}': {error}",
config_path.display()
),
)
},
)?;
Ok(HelperApplyResult {
success: true,
changed: true,
action: "proxifyre.apply-detected-config".to_string(),
message: format!(
"Сгенерированный конфиг записан в найденную установку ProxiFyre: {}",
config_path.display()
),
})
safe_fs::write_restricted_with_backup(path, contents.as_bytes()).map_err(storage_error)
}
fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyResult, CommandError> {
@@ -204,6 +226,20 @@ fn staged_apply_result(request: HelperApplyRequest<'_>) -> Result<HelperApplyRes
})
}
fn staged_managed_current_result(
request: HelperApplyRequest<'_>,
) -> Result<HelperApplyResult, CommandError> {
Ok(HelperApplyResult {
success: true,
changed: true,
action: format!("{}.stage-managed-config", request.adapter_id),
message: format!(
"Сгенерированный конфиг подготовлен в {}; служба получит его при следующем явном запуске",
request.config_path.display()
),
})
}
fn activity_for_apply(
clock: &impl Clock,
generated: &ProxyRouterGeneratedConfig,
+274
View File
@@ -0,0 +1,274 @@
//! Source/prepared/activation are separate facts. This module never controls services.
use crate::{
configuration_transaction,
privileged_jobs::{ManagedComponent, PrivilegedJobStore},
process::{self, KnownWindowsService, ServiceState},
safe_fs,
storage::JsonStorage,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{fs, io, path::PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PreparedArtifact {
source_fingerprint: String,
config_sha256: String,
}
#[derive(Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PreparedState {
proxifyre: Option<PreparedArtifact>,
singbox: Option<PreparedArtifact>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ActivationState {
Unknown,
Stopped,
RestartRequired,
Confirmed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactStatus {
pub component: String,
pub source_matches_prepared: bool,
pub generated_exists: bool,
pub activation: ActivationState,
}
pub fn prepared_path(storage: &JsonStorage) -> PathBuf {
storage
.paths()
.state_dir
.join("prepared-configuration.json")
}
fn generated_path(storage: &JsonStorage, component: ManagedComponent) -> PathBuf {
storage.paths().generated_dir.join(match component {
ManagedComponent::Proxifyre => "proxifyre-app-config.json",
ManagedComponent::SingBox => "sing-box-config.json",
})
}
fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn source_fingerprint(storage: &JsonStorage, component: ManagedComponent) -> io::Result<String> {
let bytes = match component {
ManagedComponent::Proxifyre => {
serde_json::to_vec(&(storage.read_profiles()?, storage.read_targets()?))?
}
ManagedComponent::SingBox => serde_json::to_vec(&(
storage.read_local_singbox_config()?,
storage.read_singbox_subscription_cache()?,
))?,
};
Ok(hash(&bytes))
}
fn read_prepared(storage: &JsonStorage) -> PreparedState {
// Missing, old, or invalid derived metadata is unknown, never reconstructed from source.
let path = prepared_path(storage);
if safe_fs::ensure_no_reparse_ancestors(&path).is_err() {
return PreparedState::default();
}
fs::read(path)
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
/// Must run inside ConfigurationTransaction after all source and generated writes.
pub fn record_prepared_locked(
storage: &JsonStorage,
component: ManagedComponent,
) -> io::Result<()> {
let path = generated_path(storage, component);
safe_fs::ensure_no_reparse_ancestors(&path)?;
let artifact = PreparedArtifact {
source_fingerprint: source_fingerprint(storage, component)?,
config_sha256: hash(&fs::read(path)?),
};
let mut state = read_prepared(storage);
match component {
ManagedComponent::Proxifyre => state.proxifyre = Some(artifact),
ManagedComponent::SingBox => state.singbox = Some(artifact),
};
safe_fs::write_restricted_atomic(&prepared_path(storage), &serde_json::to_vec(&state)?)
}
pub fn read_status_locked(storage: &JsonStorage) -> io::Result<Vec<ArtifactStatus>> {
let prepared = read_prepared(storage);
let store = PrivilegedJobStore::production().ok();
[ManagedComponent::Proxifyre, ManagedComponent::SingBox]
.into_iter()
.map(|component| {
let path = generated_path(storage, component);
safe_fs::ensure_no_reparse_ancestors(&path)?;
let generated = fs::read(path).ok().map(|bytes| hash(&bytes));
let artifact = match component {
ManagedComponent::Proxifyre => &prepared.proxifyre,
ManagedComponent::SingBox => &prepared.singbox,
};
let source = source_fingerprint(storage, component)?;
let source_matches_prepared = artifact.as_ref().is_some_and(|record| {
record.source_fingerprint == source
&& generated.as_ref() == Some(&record.config_sha256)
});
let service = match component {
ManagedComponent::Proxifyre => KnownWindowsService::Proxifyre,
ManagedComponent::SingBox => KnownWindowsService::SingBox,
};
let ack = store
.as_ref()
.and_then(|store| store.read_activation(component).ok().flatten());
let current = process::running_service_instance(service).ok();
let managed = match component {
ManagedComponent::Proxifyre => crate::component_detection::inventory_proxyfier(),
ManagedComponent::SingBox => crate::component_detection::inventory_singbox(),
}
.classification()
== crate::component_inventory::ComponentClassification::ManagedCurrent;
let stopped = process::query_known_service(service)
.ok()
.is_some_and(|state| !state.exists || state.state == Some(ServiceState::Stopped));
let activation = classify_activation(
source_matches_prepared,
generated.as_deref(),
ack.as_ref(),
current,
managed,
stopped,
);
Ok(ArtifactStatus {
component: match component {
ManagedComponent::Proxifyre => "proxyfier",
ManagedComponent::SingBox => "singbox",
}
.into(),
source_matches_prepared,
generated_exists: generated.is_some(),
activation,
})
})
.collect()
}
pub fn read_status(storage: &JsonStorage) -> io::Result<Vec<ArtifactStatus>> {
let _guard = configuration_transaction::read_guard(storage)?;
read_status_locked(storage)
}
fn classify_activation(
prepared: bool,
generated: Option<&str>,
ack: Option<&crate::privileged_jobs::ActivationAcknowledgement>,
current: Option<process::ServiceInstance>,
managed: bool,
stopped: bool,
) -> ActivationState {
if stopped {
return ActivationState::Stopped;
}
match (ack, current) {
(Some(ack), Some(current)) if managed && ack.instance == current => {
if prepared && generated == Some(ack.config_sha256.as_str()) {
ActivationState::Confirmed
} else {
ActivationState::RestartRequired
}
}
_ => ActivationState::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn late_activation_never_confirms_new_preparation_or_a_reused_pid() {
let instance = process::ServiceInstance {
process_id: 42,
created_at_filetime: 100,
};
let ack = crate::privileged_jobs::ActivationAcknowledgement {
component: ManagedComponent::Proxifyre,
config_sha256: "a".repeat(64),
instance,
};
assert_eq!(
classify_activation(
true,
Some(&ack.config_sha256),
Some(&ack),
Some(instance),
true,
false
),
ActivationState::Confirmed
);
assert_eq!(
classify_activation(
true,
Some(&"b".repeat(64)),
Some(&ack),
Some(instance),
true,
false
),
ActivationState::RestartRequired
);
assert_eq!(
classify_activation(
false,
Some(&ack.config_sha256),
Some(&ack),
Some(instance),
true,
false
),
ActivationState::RestartRequired
);
assert_eq!(
classify_activation(
true,
Some(&ack.config_sha256),
Some(&ack),
Some(process::ServiceInstance {
created_at_filetime: 101,
..instance
}),
true,
false
),
ActivationState::Unknown
);
assert_eq!(
classify_activation(
true,
Some(&ack.config_sha256),
Some(&ack),
Some(instance),
false,
false
),
ActivationState::Unknown
);
assert_eq!(
classify_activation(
true,
Some(&ack.config_sha256),
None,
Some(instance),
true,
false
),
ActivationState::Unknown
);
assert_eq!(
classify_activation(true, Some(&ack.config_sha256), Some(&ack), None, true, true),
ActivationState::Stopped
);
}
}
+4712 -20
View File
File diff suppressed because it is too large Load Diff
+23 -6
View File
@@ -6,12 +6,12 @@ use crate::adapters::singbox::{
};
use crate::clock::Clock;
use crate::command_dto::{ActivityEntryDto, CommandError, GenerateSingBoxConfigResponse};
use crate::configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction};
use crate::models::{
ActivityEntry, ActivityLevel, ComponentId, LocalSingBoxConfig, ProxyProtocol, Target,
TargetKind,
};
use crate::safe_fs;
use crate::singbox_subscription::read_required_singbox_cache;
use crate::storage::JsonStorage;
use std::path::Path;
@@ -25,8 +25,19 @@ pub fn generate_singbox_config_with_services<C>(
where
C: SingBoxConfigChecker,
{
let guard = read_guard(storage).map_err(storage_error)?;
let config = storage.read_local_singbox_config().map_err(storage_error)?;
let cache = read_required_singbox_cache(storage)?;
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?
.ok_or_else(|| {
CommandError::new(
"singbox_subscription_cache_missing",
"Сначала загрузите подписку.",
)
})?;
let revision = revision_locked(storage).map_err(storage_error)?;
drop(guard);
let generated = adapter
.generate_config(
SingBoxGenerationRequest::new(&config, &cache, binary_path),
@@ -38,13 +49,19 @@ where
.generated_dir
.join(generated.output_file_name.as_str());
let transaction =
ConfigurationTransaction::begin(storage, Some(&revision)).map_err(storage_error)?;
write_generated_config(&generated_path, &generated.contents)?;
ensure_local_singbox_target(storage, &config)?;
crate::route_state::record_prepared_locked(
storage,
crate::privileged_jobs::ManagedComponent::SingBox,
)
.map_err(storage_error)?;
transaction.commit().map_err(storage_error)?;
let activity = activity_for_singbox_generate(clock, &generated, &generated_path);
storage
.append_activity(activity.clone())
.map_err(storage_error)?;
let _ = storage.append_activity(activity.clone());
Ok(GenerateSingBoxConfigResponse {
success: true,
@@ -117,7 +134,7 @@ fn singbox_adapter_error(error: SingBoxConfigError) -> CommandError {
}
fn write_generated_config(path: &Path, contents: &str) -> Result<(), CommandError> {
safe_fs::write_with_backup(path, contents.as_bytes()).map_err(storage_error)
safe_fs::write_restricted_with_backup(path, contents.as_bytes()).map_err(storage_error)
}
fn storage_error(error: std::io::Error) -> CommandError {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+106 -167
View File
@@ -1,32 +1,121 @@
use crate::component_detection::DetectedSingBox;
use crate::models::{DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT, DEFAULT_LOCAL_SINGBOX_SERVICE_NAME};
use crate::process::service_path_matches_exact;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const WINSW_WRAPPER_FILE: &str = "ProxyWardenSingBox.exe";
pub const WINSW_SERVICE_XML_FILE: &str = "ProxyWardenSingBox.xml";
pub const SINGBOX_RUNTIME_FILE: &str = "sing-box.exe";
pub const SINGBOX_CRONET_FILE: &str = "libcronet.dll";
pub const SINGBOX_LICENSE_FILE: &str = "LICENSE";
pub const SINGBOX_RUNTIME_CONFIG_FILE: &str = "config.json";
pub const SINGBOX_OWNERSHIP_MARKER_FILE: &str = "proxywarden-singbox.json";
/// WinSW expands `%BASE%` to the sealed component root. The fixed two-parent
/// hop lands at the verified Control App root while keeping wrapper output out
/// of the immutable runtime inventory.
pub const SINGBOX_SERVICE_LOG_DIR: &str = r"%BASE%\..\..\.proxywarden-service-logs\sing-box";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SingBoxServiceAction {
Start,
Stop,
pub enum SingBoxNativeServiceState {
Missing,
Stopped,
Running,
Pending,
}
impl SingBoxServiceAction {
pub fn action_name(self) -> &'static str {
match self {
SingBoxServiceAction::Start => "start",
SingBoxServiceAction::Stop => "stop",
/// Fresh SCM state queried at the privileged boundary. `path_name` is the raw
/// `QueryServiceConfigW` value; the policy compares it with the one fixed
/// wrapper path and rejects arguments or another executable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxNativeServiceSnapshot {
pub state: SingBoxNativeServiceState,
pub path_name: Option<String>,
pub demand_start: bool,
pub failure_recovery_disabled: bool,
pub builtin_users_can_start: bool,
}
impl SingBoxNativeServiceSnapshot {
pub fn missing() -> Self {
Self {
state: SingBoxNativeServiceState::Missing,
path_name: None,
demand_start: false,
failure_recovery_disabled: false,
builtin_users_can_start: false,
}
}
pub fn label(self) -> &'static str {
match self {
SingBoxServiceAction::Start => "запустить",
SingBoxServiceAction::Stop => "остановить",
}
pub fn matches_managed_policy(&self, spec: &SingBoxServiceInstallSpec) -> bool {
self.path_name
.as_deref()
.is_some_and(|path_name| service_path_matches_exact(path_name, &spec.wrapper_path))
&& self.demand_start
&& self.failure_recovery_disabled
&& !self.builtin_users_can_start
}
}
/// Fixed native SCM creation contract. A host maps this directly to
/// `CreateServiceW`/`ChangeServiceConfig2W`; there is no caller-supplied
/// command line or service name.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SingBoxServiceInstallSpec {
pub service_name: &'static str,
pub display_name: &'static str,
pub wrapper_path: PathBuf,
pub command_line: String,
pub demand_start: bool,
pub failure_recovery_disabled: bool,
pub builtin_users_can_start: bool,
}
impl SingBoxServiceInstallSpec {
pub fn for_install_root(install_root: &Path) -> Option<Self> {
if !install_root.is_absolute()
|| install_root.file_name().and_then(|name| name.to_str()) != Some("sing-box")
{
return None;
}
let wrapper_path = install_root.join(WINSW_WRAPPER_FILE);
let command_line = quote_windows_executable(&wrapper_path)?;
Some(Self {
service_name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME,
display_name: "ProxyWarden Local sing-box",
wrapper_path,
command_line,
demand_start: true,
failure_recovery_disabled: true,
builtin_users_can_start: false,
})
}
}
pub fn singbox_service_xml() -> &'static str {
concat!(
"<service>\r\n",
" <id>ProxyWardenSingBox</id>\r\n",
" <name>ProxyWarden Local sing-box</name>\r\n",
" <description>Local sing-box runtime managed by ProxyWarden</description>\r\n",
" <executable>%BASE%\\sing-box.exe</executable>\r\n",
" <arguments>run -c &quot;%BASE%\\config.json&quot;</arguments>\r\n",
" <startmode>Manual</startmode>\r\n",
" <onfailure action=\"none\" />\r\n",
" <logpath>%BASE%\\..\\..\\.proxywarden-service-logs\\sing-box</logpath>\r\n",
" <log mode=\"none\"/>\r\n",
"</service>\r\n",
)
}
fn quote_windows_executable(path: &Path) -> Option<String> {
let value = path.to_str()?;
if value.is_empty() || value.contains(['\0', '"', '\r', '\n']) {
return None;
}
Some(format!("\"{value}\""))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SingBoxSetupStatus {
@@ -45,16 +134,6 @@ pub struct SingBoxSetupItem {
pub details: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServiceCommandOutput {
pub success: bool,
pub code: String,
pub service_name: Option<String>,
pub status: Option<String>,
pub process_id: Option<u32>,
}
pub fn build_singbox_setup_status(detected: Option<&DetectedSingBox>) -> SingBoxSetupStatus {
build_singbox_setup_status_with_install_root(
detected,
@@ -74,7 +153,7 @@ pub fn build_singbox_setup_status_with_install_root(
id: "sing-box-binary".to_string(),
name: "sing-box".to_string(),
installed: true,
version: Some("binary найден".to_string()),
version: singbox.version.clone(),
details: singbox.executable_path.display().to_string(),
},
_ => SingBoxSetupItem {
@@ -92,7 +171,7 @@ pub fn build_singbox_setup_status_with_install_root(
id: "winsw-wrapper".to_string(),
name: "WinSW service wrapper".to_string(),
installed: true,
version: Some("wrapper найден".to_string()),
version: singbox.wrapper_version.clone(),
details: singbox.wrapper_path.display().to_string(),
},
_ => SingBoxSetupItem {
@@ -110,14 +189,14 @@ pub fn build_singbox_setup_status_with_install_root(
id: "windows-service".to_string(),
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
installed: true,
version: Some("служба запущена".to_string()),
version: None,
details: format!("Служба {}", singbox.service_name),
},
Some(singbox) => SingBoxSetupItem {
id: "windows-service".to_string(),
name: DEFAULT_LOCAL_SINGBOX_SERVICE_NAME.to_string(),
installed: true,
version: Some("служба остановлена".to_string()),
version: None,
details: format!("Служба {}", singbox.service_name),
},
None => SingBoxSetupItem {
@@ -138,143 +217,3 @@ pub fn build_singbox_setup_status_with_install_root(
items,
}
}
pub fn parse_service_command_output(stdout: &[u8]) -> Option<ServiceCommandOutput> {
let stdout = String::from_utf8_lossy(stdout);
let payload = stdout
.lines()
.rev()
.map(str::trim)
.find(|line| line.starts_with('{') && line.ends_with('}'))?;
serde_json::from_str(payload).ok()
}
pub fn ensure_safe_singbox_install_dir(path: &Path) -> Result<(), String> {
let normalized = path
.display()
.to_string()
.replace('/', "\\")
.to_ascii_lowercase();
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let is_proxywarden_component = normalized.contains("\\proxywarden\\components\\");
let is_legacy_proxywarden_child = normalized.ends_with("\\proxywarden\\sing-box");
if file_name == "sing-box" && (is_proxywarden_component || is_legacy_proxywarden_child) {
return Ok(());
}
Err(format!(
"Отказываюсь рекурсивно удалять Local sing-box с небезопасным путем: {}",
path.display()
))
}
pub fn service_control_script(
action: SingBoxServiceAction,
service_name: &str,
config_source: Option<&Path>,
config_target: Option<&Path>,
) -> String {
let action_name = action.action_name();
let escaped_service_name = escape_powershell_single(service_name);
let escaped_config_source = config_source
.map(|path| escape_powershell_single(&path.display().to_string()))
.unwrap_or_default();
let escaped_config_target = config_target
.map(|path| escape_powershell_single(&path.display().to_string()))
.unwrap_or_default();
format!(
r#"
$ErrorActionPreference = 'Stop'
$serviceName = '{escaped_service_name}'
$action = '{action_name}'
$configSource = '{escaped_config_source}'
$configTarget = '{escaped_config_target}'
function Get-ServiceProcessId([string]$name) {{
$escapedName = $name.Replace("'", "''")
$record = Get-CimInstance Win32_Service -Filter "Name='$escapedName'" -ErrorAction SilentlyContinue
if ($null -eq $record) {{ return 0 }}
return [int]$record.ProcessId
}}
function Get-ServiceStatus([string]$name) {{
$current = Get-Service -Name $name -ErrorAction SilentlyContinue
if ($null -eq $current) {{ return $null }}
return $current.Status.ToString()
}}
function Write-ServiceResult([bool]$success, [string]$code, [string]$status, [int]$processId) {{
[PSCustomObject]@{{
success = $success
code = $code
serviceName = $serviceName
status = $status
processId = $processId
}} | ConvertTo-Json -Compress
exit 0
}}
function Sync-ServiceConfig {{
if ($action -ne 'start' -or [string]::IsNullOrWhiteSpace($configSource)) {{ return }}
if (-not (Test-Path -LiteralPath $configSource)) {{
Write-ServiceResult $false 'config_source_missing' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
}}
if ([string]::IsNullOrWhiteSpace($configTarget)) {{ return }}
try {{
Copy-Item -LiteralPath $configSource -Destination $configTarget -Force -ErrorAction Stop
}} catch {{
Write-ServiceResult $false 'config_sync_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
}}
}}
$service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue
if ($null -eq $service) {{
Write-ServiceResult $false 'service_not_found' $null 0
}}
if ($action -eq 'start') {{
Sync-ServiceConfig
if ($service.Status -eq 'Running') {{
Write-ServiceResult $true 'already_running' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
}}
try {{
Start-Service -Name $serviceName -ErrorAction Stop
$service = Get-Service -Name $serviceName -ErrorAction Stop
$service.WaitForStatus('Running', [TimeSpan]::FromSeconds(15))
}} catch {{
Write-ServiceResult $false 'start_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
}}
Write-ServiceResult ($service.Status -eq 'Running') 'started' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
}}
if ($service.Status -eq 'Stopped') {{
Write-ServiceResult $true 'already_stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
}}
try {{
Stop-Service -Name $serviceName -Force -ErrorAction Stop
$service = Get-Service -Name $serviceName -ErrorAction Stop
$service.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(15))
}} catch {{
Write-ServiceResult $false 'stop_failed' (Get-ServiceStatus $serviceName) (Get-ServiceProcessId $serviceName)
}}
Write-ServiceResult ($service.Status -eq 'Stopped') 'stopped' $service.Status.ToString() (Get-ServiceProcessId $serviceName)
"#
)
}
fn escape_powershell_single(value: &str) -> String {
value.replace('\'', "''")
}
+132 -53
View File
@@ -5,6 +5,7 @@ use crate::command_dto::*;
use crate::component_detection::{
detect_singbox_install, singbox_component_from_detection, DetectedSingBox,
};
use crate::configuration_transaction::{read_guard, revision_locked, ConfigurationTransaction};
use crate::models::{
ActivityEntry, ActivityLevel, LocalSingBoxConfig, SubscriptionCache, SubscriptionServer,
};
@@ -59,15 +60,26 @@ pub(crate) fn read_singbox_status_with_detection(
storage: &JsonStorage,
detected: Option<&DetectedSingBox>,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let _guard = read_guard(storage).map_err(storage_error)?;
let config = storage.read_local_singbox_config().map_err(storage_error)?;
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?;
status_from_source(storage, &config, cache.as_ref(), detected)
}
fn status_from_source(
storage: &JsonStorage,
config: &LocalSingBoxConfig,
cache: Option<&SubscriptionCache>,
detected: Option<&DetectedSingBox>,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let component = singbox_component_from_detection(detected);
Ok(LocalSingBoxStatusResponse {
config: LocalSingBoxConfigDto::from(&config),
cache: cache.as_ref().map(SubscriptionCacheDto::from),
saved_state: crate::configuration_use_case::read_saved_state_locked(storage)?,
config: LocalSingBoxConfigDto::from(config),
cache: cache.map(SubscriptionCacheDto::from),
component: ComponentStatusDto::from(&component),
generated_config_path: storage
.paths()
@@ -86,10 +98,18 @@ pub fn save_singbox_subscription_to_storage(
input: SaveSingBoxSubscriptionInputDto,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
let subscription_url = input.subscription_url.trim().to_string();
validate_subscription_url(&subscription_url)?;
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
if config.subscription_url.as_deref() != Some(&subscription_url) {
storage
.remove_singbox_subscription_cache()
.map_err(storage_error)?;
config.selected_server_id = None;
config.selected_server_tag = None;
}
config.subscription_url = Some(subscription_url);
ensure_device_hwid(&mut config);
config.updated_at = Some(clock.now());
@@ -97,7 +117,17 @@ pub fn save_singbox_subscription_to_storage(
.write_local_singbox_config(&config)
.map_err(storage_error)?;
read_singbox_status(storage)
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?;
let mut result = status_from_source(
storage,
&config,
cache.as_ref(),
detect_singbox_install().as_ref(),
)?;
result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?;
Ok(result)
}
pub fn fetch_singbox_subscription_with_fetcher(
@@ -105,7 +135,21 @@ pub fn fetch_singbox_subscription_with_fetcher(
fetcher: &impl SubscriptionFetcher,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
fetch_singbox_subscription_candidate(storage, None, fetcher, clock)
}
pub fn fetch_singbox_subscription_candidate(
storage: &JsonStorage,
candidate_url: Option<&str>,
fetcher: &impl SubscriptionFetcher,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let guard = read_guard(storage).map_err(storage_error)?;
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
if let Some(candidate) = candidate_url {
validate_subscription_url(candidate.trim())?;
config.subscription_url = Some(candidate.trim().to_string());
}
let subscription_url = config
.subscription_url
.as_deref()
@@ -119,28 +163,28 @@ pub fn fetch_singbox_subscription_with_fetcher(
)
})?;
let device_hwid_created = ensure_device_hwid(&mut config);
if device_hwid_created {
config.updated_at = Some(clock.now());
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
}
ensure_device_hwid(&mut config);
let revision = revision_locked(storage).map_err(storage_error)?;
drop(guard);
let identity =
subscription::SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref());
let cache = fetcher
.fetch_subscription(&subscription_url, &identity)
.map_err(|error| CommandError::new("singbox_subscription_fetch_failed", error.message))?;
let selected_server = config
.selected_server_id
.as_deref()
.and_then(|id| cache.servers.iter().find(|server| server.id == id))
.or_else(|| {
let tag = config.selected_server_tag.as_deref()?;
cache.servers.iter().find(|server| server.tag == tag)
})
.or_else(|| cache.servers.first());
let transaction = ConfigurationTransaction::begin(storage, Some(&revision)).map_err(|_| {
CommandError::new(
"configuration_changed",
"Настройки изменились во время загрузки. Повторите обновление подписки.",
)
})?;
let selected_server = if let Some(id) = config.selected_server_id.as_deref() {
cache.servers.iter().find(|server| server.id == id)
} else if let Some(tag) = config.selected_server_tag.as_deref() {
find_subscription_server(&cache, None, tag, None, None)
} else {
cache.servers.first()
};
config.selected_server_id = selected_server.map(|server| server.id.clone());
config.selected_server_tag = selected_server.map(|server| server.tag.clone());
@@ -151,23 +195,35 @@ pub fn fetch_singbox_subscription_with_fetcher(
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
storage
.append_activity(ActivityEntry {
id: "singbox-subscription-fetched".to_string(),
at: clock.now(),
level: ActivityLevel::Success,
title: "Подписка Local sing-box обновлена".to_string(),
message: format!("Серверов найдено: {}", cache.servers.len()),
})
.map_err(storage_error)?;
read_singbox_status(storage)
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?;
let mut result = status_from_source(
storage,
&config,
cache.as_ref(),
detect_singbox_install().as_ref(),
)?;
result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?;
let _ = storage.append_activity(ActivityEntry {
id: "singbox-subscription-fetched".to_string(),
at: clock.now(),
level: ActivityLevel::Success,
title: "Подписка Local sing-box обновлена".to_string(),
message: format!(
"Серверов найдено: {}",
result.cache.as_ref().map_or(0, |cache| cache.servers.len())
),
});
Ok(result)
}
pub fn forget_singbox_subscription_in_storage(
storage: &JsonStorage,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
let mut config = storage.read_local_singbox_config().map_err(storage_error)?;
config.subscription_url = None;
config.selected_server_tag = None;
@@ -176,11 +232,24 @@ pub fn forget_singbox_subscription_in_storage(
storage
.write_local_singbox_config(&config)
.map_err(storage_error)?;
storage
.discard_local_singbox_config_backup()
.map_err(storage_error)?;
storage
.remove_singbox_subscription_cache()
.map_err(storage_error)?;
read_singbox_status(storage)
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?;
let mut result = status_from_source(
storage,
&config,
cache.as_ref(),
detect_singbox_install().as_ref(),
)?;
result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?;
Ok(result)
}
pub fn select_singbox_server_in_storage(
@@ -188,6 +257,7 @@ pub fn select_singbox_server_in_storage(
input: SelectSingBoxServerInputDto,
clock: &impl Clock,
) -> Result<LocalSingBoxStatusResponse, CommandError> {
let transaction = ConfigurationTransaction::begin(storage, None).map_err(storage_error)?;
let requested_tag = input.tag.trim().to_string();
let requested_id = input
.id
@@ -233,7 +303,17 @@ pub fn select_singbox_server_in_storage(
.write_local_singbox_config(&config)
.map_err(storage_error)?;
read_singbox_status(storage)
let cache = storage
.read_singbox_subscription_cache()
.map_err(storage_error)?;
let mut result = status_from_source(
storage,
&config,
cache.as_ref(),
detect_singbox_install().as_ref(),
)?;
result.saved_state.revision = transaction.commit_with_revision().map_err(storage_error)?;
Ok(result)
}
pub fn ping_singbox_server_in_storage(
@@ -267,6 +347,7 @@ pub fn ping_all_singbox_servers_in_storage(
pub(crate) fn read_required_singbox_cache(
storage: &JsonStorage,
) -> Result<SubscriptionCache, CommandError> {
let _guard = read_guard(storage).map_err(storage_error)?;
storage
.read_singbox_subscription_cache()
.map_err(storage_error)?
@@ -338,28 +419,26 @@ fn find_subscription_server<'a>(
requested_server: Option<&str>,
requested_port: Option<u16>,
) -> Option<&'a SubscriptionServer> {
requested_id
.and_then(|id| cache.servers.iter().find(|server| server.id == id))
.or_else(|| {
cache
.servers
.iter()
.find(|server| server.tag == requested_tag)
})
.or_else(|| {
let requested = comparable_server_tag(requested_tag);
cache
.servers
.iter()
.find(|server| comparable_server_tag(&server.tag) == requested)
})
.or_else(|| {
let server_name = requested_server?.trim();
let server_port = requested_port?;
cache.servers.iter().find(|server| {
server.server.eq_ignore_ascii_case(server_name) && server.server_port == server_port
})
})
if let Some(id) = requested_id {
return cache.servers.iter().find(|server| server.id == id);
}
let tag = comparable_server_tag(requested_tag);
let mut matches = cache.servers.iter().filter(|server| {
comparable_server_tag(&server.tag) == tag
&& requested_server.is_none_or(|host| server.server.eq_ignore_ascii_case(host.trim()))
&& requested_port.is_none_or(|port| server.server_port == port)
});
if let Some(found) = matches.next() {
return matches.next().is_none().then_some(found);
}
let host = requested_server?.trim();
let port = requested_port?;
let mut endpoints = cache
.servers
.iter()
.filter(|server| server.server.eq_ignore_ascii_case(host) && server.server_port == port);
let found = endpoints.next()?;
endpoints.next().is_none().then_some(found)
}
fn comparable_server_tag(value: &str) -> String {
+148 -25
View File
@@ -1,6 +1,11 @@
use crate::activity::{append_activity, cap_activity, DEFAULT_ACTIVITY_LIMIT};
use crate::component_cutover::{
validate_component_cutover_observation, validate_component_cutover_user_evidence,
ComponentCutoverObservation, ComponentCutoverUserEvidence,
};
use crate::models::{
ActivityEntry, ComponentStatus, LocalSingBoxConfig, Profile, SubscriptionCache, Target,
ActivityEntry, ComponentLayoutMeta, LocalSingBoxConfig, Profile, StorageMeta,
SubscriptionCache, Target, DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT,
};
use crate::safe_fs;
use serde::{de::DeserializeOwned, Serialize};
@@ -17,11 +22,19 @@ pub struct StoragePaths {
pub root: PathBuf,
pub config_dir: PathBuf,
pub state_dir: PathBuf,
pub packages_dir: PathBuf,
pub generated_dir: PathBuf,
pub profiles_file: PathBuf,
pub targets_file: PathBuf,
pub components_file: PathBuf,
pub local_singbox_file: PathBuf,
pub storage_meta_file: PathBuf,
pub component_layout_file: PathBuf,
pub component_updates_file: PathBuf,
pub component_cutover_observation_file: PathBuf,
pub component_cutover_user_evidence_file: PathBuf,
pub migrations_dir: PathBuf,
pub privileged_jobs_dir: PathBuf,
pub singbox_subscription_cache_file: PathBuf,
pub activity_file: PathBuf,
}
@@ -32,17 +45,30 @@ impl StoragePaths {
let config_dir = root.join("config");
let state_dir = root.join("state");
let generated_dir = root.join("generated");
let packages_dir = root.join("packages");
Self {
root,
profiles_file: config_dir.join("profiles.json"),
targets_file: config_dir.join("targets.json"),
// Legacy migration input only. Live component status is always
// rebuilt from native inventory and never read from this file.
components_file: config_dir.join("components.json"),
local_singbox_file: config_dir.join("local-singbox.json"),
storage_meta_file: config_dir.join("storage-meta.json"),
component_layout_file: state_dir.join("component-layout.json"),
component_updates_file: state_dir.join("component-updates.json"),
component_cutover_observation_file: state_dir
.join("component-cutover-observation.json"),
component_cutover_user_evidence_file: state_dir
.join("component-cutover-user-evidence.json"),
migrations_dir: state_dir.join("migrations"),
privileged_jobs_dir: state_dir.join("privileged-jobs"),
singbox_subscription_cache_file: state_dir.join("singbox-subscription-cache.json"),
activity_file: state_dir.join("activity.json"),
config_dir,
state_dir,
packages_dir,
generated_dir,
}
}
@@ -92,14 +118,13 @@ impl JsonStorage {
self.write_json(&self.paths.targets_file, targets)
}
pub fn read_components(&self) -> io::Result<Vec<ComponentStatus>> {
self.read_json_or_default(&self.paths.components_file)
}
pub fn read_local_singbox_config(&self) -> io::Result<LocalSingBoxConfig> {
let mut config: LocalSingBoxConfig =
self.read_json_or_default(&self.paths.local_singbox_file)?;
config.normalize_percent_encoded_tags();
// The persisted pre-1.2 install_root is legacy discovery input only.
// Runtime layout is owned by component inventory, not user storage.
config.install_root = DEFAULT_LOCAL_SINGBOX_INSTALL_ROOT.to_string();
Ok(config)
}
@@ -107,6 +132,97 @@ impl JsonStorage {
self.write_json(&self.paths.local_singbox_file, config)
}
pub fn read_storage_meta(&self) -> io::Result<Option<StorageMeta>> {
self.read_optional_json(&self.paths.storage_meta_file)
}
pub fn write_storage_meta(&self, meta: &StorageMeta) -> io::Result<()> {
self.write_json(&self.paths.storage_meta_file, meta)
}
pub fn read_component_layout(&self) -> io::Result<Option<ComponentLayoutMeta>> {
self.read_optional_json(&self.paths.component_layout_file)
}
pub fn write_component_layout(&self, layout: &ComponentLayoutMeta) -> io::Result<()> {
self.write_json(&self.paths.component_layout_file, layout)
}
pub fn read_component_cutover_observation(
&self,
) -> io::Result<Option<ComponentCutoverObservation>> {
let path = &self.paths.component_cutover_observation_file;
match fs::read_to_string(path) {
Ok(contents) => {
let observation: ComponentCutoverObservation = parse_json(path, &contents)?;
validate_component_cutover_observation(&observation).map_err(|_| {
io::Error::new(
ErrorKind::InvalidData,
"invalid component cutover observation",
)
})?;
Ok(Some(observation))
}
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
pub fn write_component_cutover_observation(
&self,
observation: &ComponentCutoverObservation,
) -> io::Result<()> {
validate_component_cutover_observation(observation).map_err(|_| {
io::Error::new(
ErrorKind::InvalidInput,
"invalid component cutover observation",
)
})?;
let contents = serde_json::to_vec_pretty(observation)
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
safe_fs::write_restricted_with_backup(
&self.paths.component_cutover_observation_file,
&contents,
)
}
/// Reads the normal-process cutover evidence without corruption recovery or
/// any other write. Elevated callers must still live-revalidate it.
pub fn read_component_cutover_user_evidence(
&self,
) -> io::Result<Option<ComponentCutoverUserEvidence>> {
let path = &self.paths.component_cutover_user_evidence_file;
match fs::read_to_string(path) {
Ok(contents) => {
let evidence: ComponentCutoverUserEvidence = parse_json(path, &contents)?;
validate_component_cutover_user_evidence(&evidence).map_err(|_| {
io::Error::new(ErrorKind::InvalidData, "invalid component cutover evidence")
})?;
Ok(Some(evidence))
}
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
pub fn write_component_cutover_user_evidence(
&self,
evidence: &ComponentCutoverUserEvidence,
) -> io::Result<()> {
validate_component_cutover_user_evidence(evidence).map_err(|_| {
io::Error::new(
ErrorKind::InvalidInput,
"invalid component cutover evidence",
)
})?;
let contents = serde_json::to_vec_pretty(evidence)
.map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
safe_fs::write_restricted_with_backup(
&self.paths.component_cutover_user_evidence_file,
&contents,
)
}
pub fn read_singbox_subscription_cache(&self) -> io::Result<Option<SubscriptionCache>> {
let mut cache = self
.read_optional_json::<SubscriptionCache>(&self.paths.singbox_subscription_cache_file)?;
@@ -121,6 +237,7 @@ impl JsonStorage {
}
pub fn remove_singbox_subscription_cache(&self) -> io::Result<()> {
remove_optional_file(&backup_path(&self.paths.singbox_subscription_cache_file))?;
match fs::remove_file(&self.paths.singbox_subscription_cache_file) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
@@ -128,6 +245,10 @@ impl JsonStorage {
}
}
pub fn discard_local_singbox_config_backup(&self) -> io::Result<()> {
remove_optional_file(&backup_path(&self.paths.local_singbox_file))
}
pub fn read_activity(&self) -> io::Result<Vec<ActivityEntry>> {
let entries = self.read_json_or_default(&self.paths.activity_file)?;
Ok(cap_activity(entries, self.activity_limit))
@@ -148,7 +269,17 @@ impl JsonStorage {
Ok(contents) => {
parse_json(path, &contents).or_else(|error| recover_corrupt_json(path, error))
}
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
Err(error) if error.kind() == ErrorKind::NotFound => {
match fs::read_to_string(backup_path(path)) {
Ok(contents) => {
let value = parse_json(&backup_path(path), &contents)?;
safe_fs::write_atomic_without_backup(path, contents.as_bytes())?;
Ok(value)
}
Err(error) if error.kind() == ErrorKind::NotFound => Ok(T::default()),
Err(error) => Err(error),
}
}
Err(error) => Err(error),
}
}
@@ -206,23 +337,21 @@ fn recover_corrupt_json<T>(path: &Path, parse_error: io::Error) -> io::Result<T>
where
T: DeserializeOwned,
{
let corrupt_path = safe_fs::corrupt_path(path);
move_corrupt_file(path, &corrupt_path)?;
let backup_path = backup_path(path);
if backup_path.exists() {
if backup_path.try_exists()? {
let backup_contents = fs::read_to_string(&backup_path)?;
match parse_json(&backup_path, &backup_contents) {
Ok(value) => {
fs::copy(&backup_path, path)?;
let corrupt_path = safe_fs::corrupt_path(path);
safe_fs::write_atomic_without_backup(&corrupt_path, &fs::read(path)?)?;
safe_fs::write_atomic_without_backup(path, backup_contents.as_bytes())?;
Ok(value)
}
Err(backup_error) => Err(io::Error::new(
ErrorKind::InvalidData,
format!(
"Invalid JSON in '{}'; corrupt file moved to '{}'; backup '{}' could not be restored: {backup_error}; original error: {parse_error}",
"Invalid JSON in '{}'; original preserved; backup '{}' could not be restored: {backup_error}; original error: {parse_error}",
path.display(),
corrupt_path.display(),
backup_path.display()
),
)),
@@ -231,24 +360,18 @@ where
Err(io::Error::new(
ErrorKind::InvalidData,
format!(
"Invalid JSON in '{}'; corrupt file moved to '{}'; no valid backup available: {parse_error}",
"Invalid JSON in '{}'; original preserved; no valid backup available: {parse_error}",
path.display(),
corrupt_path.display()
),
))
}
}
fn move_corrupt_file(path: &Path, corrupt_path: &Path) -> io::Result<()> {
match fs::rename(path, corrupt_path) {
fn remove_optional_file(path: &Path) -> io::Result<()> {
safe_fs::ensure_no_reparse_ancestors(path)?;
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(rename_error) => {
fs::copy(path, corrupt_path)?;
fs::remove_file(path)?;
if !corrupt_path.exists() {
return Err(rename_error);
}
Ok(())
}
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
+7 -4
View File
@@ -175,7 +175,10 @@ pub fn fetch_subscription_with_identity_and_policy(
}
let response = request.send().map_err(|error| {
SubscriptionError::new(format!("Subscription request failed: {error}"))
SubscriptionError::new(format!(
"Subscription request failed: {}",
error.without_url()
))
})?;
let status = response.status();
if status.is_redirection() {
@@ -209,8 +212,8 @@ pub fn fetch_subscription_with_identity_and_policy(
.get("subscription-userinfo")
.and_then(|value| value.to_str().ok()),
);
let body = response.text().map_err(|error| {
SubscriptionError::new(format!("Subscription body read failed: {error}"))
let body = response.text().map_err(|_error| {
SubscriptionError::new("Subscription body read failed".to_string())
})?;
let parsed = parse_subscription_body(&body)?;
@@ -615,7 +618,7 @@ fn server_from_outbound(outbound: &Value) -> Option<SubscriptionServer> {
})
}
fn outbound_server_id(outbound: &Value) -> String {
pub(crate) fn outbound_server_id(outbound: &Value) -> String {
let bytes = serde_json::to_vec(outbound).unwrap_or_default();
let hash = bytes.iter().fold(0xcbf29ce484222325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)