@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 { 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);
|
||||
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);
|
||||
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."
|
||||
: ""
|
||||
|
||||
@@ -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