Release v2.0.0

This commit is contained in:
2026-09-11 18:29:33 +03:00
parent c5532f2087
commit 610ea7d39a
8 changed files with 192 additions and 19 deletions
+1 -5
View File
@@ -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();
+84
View File
@@ -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"),
}
}
+17 -14
View File
@@ -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."
: ""
+21
View File
@@ -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("");
});
});
+11
View File
@@ -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)}`;
}
}