From c6b66a4ed7a95936fc39b17dbc77d5b0bd74f027 Mon Sep 17 00:00:00 2001 From: dokril Date: Fri, 11 Sep 2026 18:29:33 +0300 Subject: [PATCH] Release v2.0.0 --- src-tauri/src/component_packages.rs | 6 +- src-tauri/src/configuration_transaction.rs | 6 ++ .../tests/component_package_update_tests.rs | 32 +++++++ .../tests/configuration_transaction_tests.rs | 20 +++++ src-tauri/tests/singbox_live_smoke.rs | 84 +++++++++++++++++++ src/app/App.tsx | 31 +++---- src/app/lib/refreshAfterAction.test.ts | 21 +++++ src/app/lib/refreshAfterAction.ts | 11 +++ 8 files changed, 192 insertions(+), 19 deletions(-) create mode 100644 src-tauri/tests/singbox_live_smoke.rs create mode 100644 src/app/lib/refreshAfterAction.test.ts create mode 100644 src/app/lib/refreshAfterAction.ts diff --git a/src-tauri/src/component_packages.rs b/src-tauri/src/component_packages.rs index 25c6ded..218f1d2 100644 --- a/src-tauri/src/component_packages.rs +++ b/src-tauri/src/component_packages.rs @@ -1288,11 +1288,7 @@ fn trusted_release_candidate( else { return Err(ComponentPackagesError::InvalidReleaseMetadata); }; - if release.id == 0 - || release.draft - || release.prerelease - || release.assets.len() > 100 - || !release.tag_name.starts_with('v') + if release.id == 0 || release.draft || release.prerelease || !release.tag_name.starts_with('v') { return Err(ComponentPackagesError::InvalidReleaseMetadata); } diff --git a/src-tauri/src/configuration_transaction.rs b/src-tauri/src/configuration_transaction.rs index 8ee133e..1745113 100644 --- a/src-tauri/src/configuration_transaction.rs +++ b/src-tauri/src/configuration_transaction.rs @@ -157,6 +157,12 @@ pub fn recover_locked(storage: &JsonStorage) -> io::Result<()> { }); } for (path, bytes) in paths.iter().zip(snapshots) { + // Old elevated versions left some readable files owned by Administrators. + // An unchanged file is already restored; rewriting it can fail and strand + // an otherwise complete rollback, blocking every subsequent guarded read. + if optional_bytes(path)? == bytes { + continue; + } match bytes { Some(bytes) => safe_fs::write_restricted_atomic(path, &bytes)?, None => remove_optional(path)?, diff --git a/src-tauri/tests/component_package_update_tests.rs b/src-tauri/tests/component_package_update_tests.rs index d7a96c2..19d88c9 100644 --- a/src-tauri/tests/component_package_update_tests.rs +++ b/src-tauri/tests/component_package_update_tests.rs @@ -105,6 +105,38 @@ fn trusted_check_persists_redacted_state_recovers_backup_and_reports_stale() { .expect("restored state must be JSON"); } +#[test] +fn large_multi_platform_release_selects_only_the_exact_windows_asset() { + let workspace = TestWorkspace::new(); + let service = workspace.service(); + let component = catalog_component(&service, ComponentId::SingBox); + let response = metadata_response( + &component, + "1.14.0", + "sing-box-1.14.0-windows-amd64.zip", + b"asset", + DigestMode::Trusted, + ); + let mut metadata: Value = serde_json::from_slice(&response.body).unwrap(); + // Real sing-box 1.14.0 publishes 167 assets. Response bytes are already bounded. + for index in 1..167 { + metadata["assets"].as_array_mut().unwrap().push(json!({ + "id": 1000 + index, + "name": format!("other-platform-{index}.zip"), + "size": 10, + "browser_download_url": format!("https://github.com/SagerNet/sing-box/releases/download/v1.14.0/other-platform-{index}.zip") + })); + } + let transport = MockTransport::new(vec![ResponseSpec::ok( + serde_json::to_vec(&metadata).unwrap(), + )]); + let checked = service + .check_for_update(ComponentId::SingBox, CHECKED_AT, &transport) + .unwrap(); + assert_eq!(checked.latest_known_version, "1.14.0"); + assert_eq!(checked.trust, UpdateCheckTrust::Trusted); +} + #[test] fn missing_or_malformed_digest_is_observable_and_never_downloaded() { for (mode, expected) in [ diff --git a/src-tauri/tests/configuration_transaction_tests.rs b/src-tauri/tests/configuration_transaction_tests.rs index 59f2ae9..7d0394e 100644 --- a/src-tauri/tests/configuration_transaction_tests.rs +++ b/src-tauri/tests/configuration_transaction_tests.rs @@ -38,6 +38,26 @@ fn interrupted_commit_restores_primary_and_backup_before_next_read() { assert!(fixture.storage.read_profiles().unwrap().is_empty()); } +#[test] +fn rollback_leaves_unchanged_files_in_place() { + let fixture = Fixture::new(); + let path = &fixture.storage.paths().profiles_file; + let transaction = ConfigurationTransaction::begin(&fixture.storage, None).unwrap(); + // Deny replacement on Windows, while allowing the recovery code to read. + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.share_mode(1); + } + let held = options.open(path).unwrap(); + let modified = held.metadata().unwrap().modified().unwrap(); + transaction.abort().unwrap(); + assert_eq!(fs::metadata(path).unwrap().modified().unwrap(), modified); + assert!(read_guard(&fixture.storage).is_ok()); +} + #[test] fn shared_lock_rejects_second_writer_and_reader() { let fixture = Fixture::new(); diff --git a/src-tauri/tests/singbox_live_smoke.rs b/src-tauri/tests/singbox_live_smoke.rs new file mode 100644 index 0000000..0404a83 --- /dev/null +++ b/src-tauri/tests/singbox_live_smoke.rs @@ -0,0 +1,84 @@ +//! Explicit checks of real saved data/network. Never runs automatically in CI. +#![cfg(all(windows, debug_assertions))] + +use proxywarden_lib::{ + component_catalog::ComponentId, + component_packages::{ + ComponentPackageService, NativePackageSignatureVerifier, ReqwestUpdateTransport, + }, + configuration_transaction::read_guard, + privileged_jobs::{EpochClock, SystemEpochClock}, + storage::{default_config_root, JsonStorage, StoragePaths}, + subscription::{fetch_subscription_with_identity, SubscriptionFetchIdentity}, +}; +use std::path::PathBuf; + +#[test] +#[ignore = "Real user data/network; requires an explicitly authorized operator action"] +fn installed_singbox_data_check() { + let action = std::env::var("PROXYWARDEN_LIVE_SINGBOX_ACTION") + .expect("set the explicitly authorized action"); + let storage = JsonStorage::new(default_config_root()); + match action.as_str() { + "recover" => { + let _guard = read_guard(&storage).expect("recover saved configuration"); + println!("Configuration recovery succeeded"); + } + "subscription" => { + let config = { + let _guard = read_guard(&storage).expect("read saved configuration"); + storage + .read_local_singbox_config() + .expect("saved subscription settings") + }; + let identity = + SubscriptionFetchIdentity::with_device_hwid(config.device_hwid.as_deref()); + let result = fetch_subscription_with_identity( + config + .subscription_url + .as_deref() + .expect("saved subscription exists"), + &identity, + ); + // Never include the subscription URL, response body, credentials or server names. + assert!(result.is_ok(), "Saved subscription fetch/parse failed"); + let cache = result.unwrap(); + assert!( + !cache.servers.is_empty(), + "subscription returned no servers" + ); + println!( + "Saved subscription fetched and parsed: {} servers", + cache.servers.len() + ); + } + "download" => { + let packages = ComponentPackageService::open( + PathBuf::from(r"C:\Program Files\ProxyWarden\bundled\components"), + &StoragePaths::default(), + ) + .expect("installed offline catalog"); + let transport = ReqwestUpdateTransport::new().expect("update transport"); + let checked = packages + .check_for_update( + ComponentId::SingBox, + SystemEpochClock.now_epoch_seconds(), + &transport, + ) + .expect("release metadata and independent digest"); + println!( + "Latest release: {}; trust {:?}", + checked.latest_known_version, checked.trust + ); + let package = packages + .download_checked_update( + ComponentId::SingBox, + &transport, + &NativePackageSignatureVerifier, + ) + .expect("verified download into application cache"); + println!("Downloaded and verified sing-box {}", package.version); + } + _ => panic!("unsupported explicit data-check action"), + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx index ebe2174..b37c9da 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -80,6 +80,7 @@ import { useApplyFlow } from "./hooks/useApplyFlow"; import { useConfigurationDraft } from "./hooks/useConfigurationDraft"; import { useNoticeLog } from "./hooks/useNoticeLog"; import { parseProxy, type ParsedProxy } from "./lib/parseProxy"; +import { refreshAfterAction } from "./lib/refreshAfterAction"; import { normalizeItemValue, type DraftItemType } from "./lib/profileItems"; import { configChangeRows, @@ -844,14 +845,15 @@ export function App() { await nextFrame(); const installResult = await installProxiFyre(); const component = installResult.component; - const detectedSetupStatus = await getProxiFyreSetupStatus(); setComponents((current) => upsertComponent(current, component)); - setSetupStatus(detectedSetupStatus); - await componentPackages.refreshLocal(); + const refreshWarning = await refreshAfterAction(async () => { + setSetupStatus(await getProxiFyreSetupStatus()); + await componentPackages.refreshLocal(); + }); showNotice({ kind: "success", title: "ProxiFyre установлен", - text: `${proxyfierDetails(component, false)}${ + text: `${proxyfierDetails(component, false)}${refreshWarning}${ installResult.rebootRequired ? " Для завершения установки перезапусти Windows." : "" @@ -905,14 +907,15 @@ export function App() { await nextFrame(); const uninstallResult = await uninstallProxiFyre(); const component = uninstallResult.component; - const detectedSetupStatus = await getProxiFyreSetupStatus(); setComponents((current) => upsertComponent(current, component)); - setSetupStatus(detectedSetupStatus); - await componentPackages.refreshLocal(); + const refreshWarning = await refreshAfterAction(async () => { + setSetupStatus(await getProxiFyreSetupStatus()); + await componentPackages.refreshLocal(); + }); showNotice({ kind: "success", title: "ProxiFyre удален", - text: `Служба, папка установки ProxiFyre и Windows Packet Filter удалены.${ + text: `Служба, папка установки ProxiFyre и Windows Packet Filter удалены.${refreshWarning}${ uninstallResult.rebootRequired ? " Для завершения удаления перезапусти Windows." : "" @@ -960,12 +963,12 @@ export function App() { ? await startSingBoxService() : await stopSingBoxService(); setComponents((current) => upsertComponent(current, component)); - await refreshSingBoxState(); + const refreshWarning = await refreshAfterAction(refreshSingBoxState); setProxyCheck(null); showNotice({ kind: "success", title: shouldRun ? "sing-box запущен" : "sing-box остановлен", - text: componentDetails(component, false), + text: componentDetails(component, false) + refreshWarning, }); } catch (error) { showPrivilegedActionFailure( @@ -990,12 +993,12 @@ export function App() { const installResult = await installSingBox(); const component = installResult.component; setComponents((current) => upsertComponent(current, component)); - await refreshSingBoxState(); + const refreshWarning = await refreshAfterAction(refreshSingBoxState); setProxyCheck(null); showNotice({ kind: "success", title: "Local sing-box установлен", - text: `${componentDetails(component, false)}${ + text: `${componentDetails(component, false)}${refreshWarning}${ installResult.rebootRequired ? " Для завершения установки перезапусти Windows." : "" @@ -1021,12 +1024,12 @@ export function App() { const uninstallResult = await uninstallSingBox(); const component = uninstallResult.component; setComponents((current) => upsertComponent(current, component)); - await refreshSingBoxState(); + const refreshWarning = await refreshAfterAction(refreshSingBoxState); setProxyCheck(null); showNotice({ kind: "success", title: "Local sing-box удален", - text: `Служба и папка установки Local sing-box удалены.${ + text: `Служба и папка установки Local sing-box удалены.${refreshWarning}${ uninstallResult.rebootRequired ? " Для завершения удаления перезапусти Windows." : "" diff --git a/src/app/lib/refreshAfterAction.test.ts b/src/app/lib/refreshAfterAction.test.ts new file mode 100644 index 0000000..396040d --- /dev/null +++ b/src/app/lib/refreshAfterAction.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { refreshAfterAction } from "./refreshAfterAction"; + +describe("refreshAfterAction", () => { + it("keeps a completed action successful when the following read is denied", async () => { + const warning = await refreshAfterAction(async () => { + throw { + code: "storage_error", + message: "Отказано в доступе. (os error 5)", + details: [], + }; + }); + expect(warning).toContain("Действие выполнено"); + expect(warning).toContain("состояние интерфейса не обновлено"); + expect(warning).toContain("Отказано в доступе"); + }); + + it("does not add a warning after a successful refresh", async () => { + expect(await refreshAfterAction(async () => undefined)).toBe(""); + }); +}); diff --git a/src/app/lib/refreshAfterAction.ts b/src/app/lib/refreshAfterAction.ts new file mode 100644 index 0000000..b6e2dfb --- /dev/null +++ b/src/app/lib/refreshAfterAction.ts @@ -0,0 +1,11 @@ +import { errorMessage } from "../viewModel"; + +/** A confirmed system action stays successful even if its following read fails. */ +export async function refreshAfterAction(refresh: () => Promise) { + try { + await refresh(); + return ""; + } catch (error) { + return ` Действие выполнено, но состояние интерфейса не обновлено: ${errorMessage(error)}`; + } +}