@@ -1288,11 +1288,7 @@ fn trusted_release_candidate(
|
|||||||
else {
|
else {
|
||||||
return Err(ComponentPackagesError::InvalidReleaseMetadata);
|
return Err(ComponentPackagesError::InvalidReleaseMetadata);
|
||||||
};
|
};
|
||||||
if release.id == 0
|
if release.id == 0 || release.draft || release.prerelease || !release.tag_name.starts_with('v')
|
||||||
|| release.draft
|
|
||||||
|| release.prerelease
|
|
||||||
|| release.assets.len() > 100
|
|
||||||
|| !release.tag_name.starts_with('v')
|
|
||||||
{
|
{
|
||||||
return Err(ComponentPackagesError::InvalidReleaseMetadata);
|
return Err(ComponentPackagesError::InvalidReleaseMetadata);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ pub fn recover_locked(storage: &JsonStorage) -> io::Result<()> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (path, bytes) in paths.iter().zip(snapshots) {
|
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 {
|
match bytes {
|
||||||
Some(bytes) => safe_fs::write_restricted_atomic(path, &bytes)?,
|
Some(bytes) => safe_fs::write_restricted_atomic(path, &bytes)?,
|
||||||
None => remove_optional(path)?,
|
None => remove_optional(path)?,
|
||||||
|
|||||||
@@ -105,6 +105,38 @@ fn trusted_check_persists_redacted_state_recovers_backup_and_reports_stale() {
|
|||||||
.expect("restored state must be JSON");
|
.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]
|
#[test]
|
||||||
fn missing_or_malformed_digest_is_observable_and_never_downloaded() {
|
fn missing_or_malformed_digest_is_observable_and_never_downloaded() {
|
||||||
for (mode, expected) in [
|
for (mode, expected) in [
|
||||||
|
|||||||
@@ -38,6 +38,26 @@ fn interrupted_commit_restores_primary_and_backup_before_next_read() {
|
|||||||
assert!(fixture.storage.read_profiles().unwrap().is_empty());
|
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]
|
#[test]
|
||||||
fn shared_lock_rejects_second_writer_and_reader() {
|
fn shared_lock_rejects_second_writer_and_reader() {
|
||||||
let fixture = Fixture::new();
|
let fixture = Fixture::new();
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-12
@@ -80,6 +80,7 @@ import { useApplyFlow } from "./hooks/useApplyFlow";
|
|||||||
import { useConfigurationDraft } from "./hooks/useConfigurationDraft";
|
import { useConfigurationDraft } from "./hooks/useConfigurationDraft";
|
||||||
import { useNoticeLog } from "./hooks/useNoticeLog";
|
import { useNoticeLog } from "./hooks/useNoticeLog";
|
||||||
import { parseProxy, type ParsedProxy } from "./lib/parseProxy";
|
import { parseProxy, type ParsedProxy } from "./lib/parseProxy";
|
||||||
|
import { refreshAfterAction } from "./lib/refreshAfterAction";
|
||||||
import { normalizeItemValue, type DraftItemType } from "./lib/profileItems";
|
import { normalizeItemValue, type DraftItemType } from "./lib/profileItems";
|
||||||
import {
|
import {
|
||||||
configChangeRows,
|
configChangeRows,
|
||||||
@@ -844,14 +845,15 @@ export function App() {
|
|||||||
await nextFrame();
|
await nextFrame();
|
||||||
const installResult = await installProxiFyre();
|
const installResult = await installProxiFyre();
|
||||||
const component = installResult.component;
|
const component = installResult.component;
|
||||||
const detectedSetupStatus = await getProxiFyreSetupStatus();
|
|
||||||
setComponents((current) => upsertComponent(current, component));
|
setComponents((current) => upsertComponent(current, component));
|
||||||
setSetupStatus(detectedSetupStatus);
|
const refreshWarning = await refreshAfterAction(async () => {
|
||||||
|
setSetupStatus(await getProxiFyreSetupStatus());
|
||||||
await componentPackages.refreshLocal();
|
await componentPackages.refreshLocal();
|
||||||
|
});
|
||||||
showNotice({
|
showNotice({
|
||||||
kind: "success",
|
kind: "success",
|
||||||
title: "ProxiFyre установлен",
|
title: "ProxiFyre установлен",
|
||||||
text: `${proxyfierDetails(component, false)}${
|
text: `${proxyfierDetails(component, false)}${refreshWarning}${
|
||||||
installResult.rebootRequired
|
installResult.rebootRequired
|
||||||
? " Для завершения установки перезапусти Windows."
|
? " Для завершения установки перезапусти Windows."
|
||||||
: ""
|
: ""
|
||||||
@@ -905,14 +907,15 @@ export function App() {
|
|||||||
await nextFrame();
|
await nextFrame();
|
||||||
const uninstallResult = await uninstallProxiFyre();
|
const uninstallResult = await uninstallProxiFyre();
|
||||||
const component = uninstallResult.component;
|
const component = uninstallResult.component;
|
||||||
const detectedSetupStatus = await getProxiFyreSetupStatus();
|
|
||||||
setComponents((current) => upsertComponent(current, component));
|
setComponents((current) => upsertComponent(current, component));
|
||||||
setSetupStatus(detectedSetupStatus);
|
const refreshWarning = await refreshAfterAction(async () => {
|
||||||
|
setSetupStatus(await getProxiFyreSetupStatus());
|
||||||
await componentPackages.refreshLocal();
|
await componentPackages.refreshLocal();
|
||||||
|
});
|
||||||
showNotice({
|
showNotice({
|
||||||
kind: "success",
|
kind: "success",
|
||||||
title: "ProxiFyre удален",
|
title: "ProxiFyre удален",
|
||||||
text: `Служба, папка установки ProxiFyre и Windows Packet Filter удалены.${
|
text: `Служба, папка установки ProxiFyre и Windows Packet Filter удалены.${refreshWarning}${
|
||||||
uninstallResult.rebootRequired
|
uninstallResult.rebootRequired
|
||||||
? " Для завершения удаления перезапусти Windows."
|
? " Для завершения удаления перезапусти Windows."
|
||||||
: ""
|
: ""
|
||||||
@@ -960,12 +963,12 @@ export function App() {
|
|||||||
? await startSingBoxService()
|
? await startSingBoxService()
|
||||||
: await stopSingBoxService();
|
: await stopSingBoxService();
|
||||||
setComponents((current) => upsertComponent(current, component));
|
setComponents((current) => upsertComponent(current, component));
|
||||||
await refreshSingBoxState();
|
const refreshWarning = await refreshAfterAction(refreshSingBoxState);
|
||||||
setProxyCheck(null);
|
setProxyCheck(null);
|
||||||
showNotice({
|
showNotice({
|
||||||
kind: "success",
|
kind: "success",
|
||||||
title: shouldRun ? "sing-box запущен" : "sing-box остановлен",
|
title: shouldRun ? "sing-box запущен" : "sing-box остановлен",
|
||||||
text: componentDetails(component, false),
|
text: componentDetails(component, false) + refreshWarning,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showPrivilegedActionFailure(
|
showPrivilegedActionFailure(
|
||||||
@@ -990,12 +993,12 @@ export function App() {
|
|||||||
const installResult = await installSingBox();
|
const installResult = await installSingBox();
|
||||||
const component = installResult.component;
|
const component = installResult.component;
|
||||||
setComponents((current) => upsertComponent(current, component));
|
setComponents((current) => upsertComponent(current, component));
|
||||||
await refreshSingBoxState();
|
const refreshWarning = await refreshAfterAction(refreshSingBoxState);
|
||||||
setProxyCheck(null);
|
setProxyCheck(null);
|
||||||
showNotice({
|
showNotice({
|
||||||
kind: "success",
|
kind: "success",
|
||||||
title: "Local sing-box установлен",
|
title: "Local sing-box установлен",
|
||||||
text: `${componentDetails(component, false)}${
|
text: `${componentDetails(component, false)}${refreshWarning}${
|
||||||
installResult.rebootRequired
|
installResult.rebootRequired
|
||||||
? " Для завершения установки перезапусти Windows."
|
? " Для завершения установки перезапусти Windows."
|
||||||
: ""
|
: ""
|
||||||
@@ -1021,12 +1024,12 @@ export function App() {
|
|||||||
const uninstallResult = await uninstallSingBox();
|
const uninstallResult = await uninstallSingBox();
|
||||||
const component = uninstallResult.component;
|
const component = uninstallResult.component;
|
||||||
setComponents((current) => upsertComponent(current, component));
|
setComponents((current) => upsertComponent(current, component));
|
||||||
await refreshSingBoxState();
|
const refreshWarning = await refreshAfterAction(refreshSingBoxState);
|
||||||
setProxyCheck(null);
|
setProxyCheck(null);
|
||||||
showNotice({
|
showNotice({
|
||||||
kind: "success",
|
kind: "success",
|
||||||
title: "Local sing-box удален",
|
title: "Local sing-box удален",
|
||||||
text: `Служба и папка установки Local sing-box удалены.${
|
text: `Служба и папка установки Local sing-box удалены.${refreshWarning}${
|
||||||
uninstallResult.rebootRequired
|
uninstallResult.rebootRequired
|
||||||
? " Для завершения удаления перезапусти Windows."
|
? " Для завершения удаления перезапусти Windows."
|
||||||
: ""
|
: ""
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<unknown>) {
|
||||||
|
try {
|
||||||
|
await refresh();
|
||||||
|
return "";
|
||||||
|
} catch (error) {
|
||||||
|
return ` Действие выполнено, но состояние интерфейса не обновлено: ${errorMessage(error)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user