From ab9813525c973f923a29ced836d97bd12a7151c1 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 10:39:52 +0800 Subject: [PATCH 1/3] Support custom VPN profiles and canonical platform namespaces Add strict profileName selection to Windows VPN lifecycle requests while preserving package ownership and single-session guards. Use the canonical Android JNI and Apple logging namespace, with updated contracts and regression coverage. --- CONTEXT.md | 4 + docs/invoke-api.md | 56 +++++- docs/windows-vpn.md | 6 +- example/windows-uwp/README.md | 13 +- include/vole_windows_uwp.h | 4 +- scripts/tests/test_platform_namespaces.py | 34 ++++ src/ffi/android.rs | 8 +- src/invoke/mod.rs | 7 +- src/platform/apple_logging.rs | 6 +- src/windows/host.rs | 219 +++++++++++++++++++--- tests/README.md | 13 +- 11 files changed, 329 insertions(+), 41 deletions(-) create mode 100644 scripts/tests/test_platform_namespaces.py diff --git a/CONTEXT.md b/CONTEXT.md index efde343..f3b6689 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,6 +16,10 @@ _Avoid_: Plugin, background task when referring to the whole participant, proxy The desktop raw-IP platform adapter backed by a host-provided Wintun DLL. It connects to the shared Vole TUN runtime without a Windows VPN Provider, package identity or packet channel; the first release leaves system network configuration to the host. _Avoid_: Windows VPN provider, second proxy core, Unix fd emulation +**Windows VPN profile**: +The Windows system VPN profile selected by the host's stable `profileName` within its package family. Start, status and stop use the same exact name; omission selects `Vole`. A custom name does not enable concurrent package sessions or migrate another profile. +_Avoid_: Proxy node, user configuration, Session Snapshot + **Windows VPN policy**: The profile-level Always On and destination-bypass choices for a Windows tunnel whose application scope is always global. _Avoid_: Per-app policy, app selection, traffic filter diff --git a/docs/invoke-api.md b/docs/invoke-api.md index bd9fdd2..87aad9f 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -345,6 +345,30 @@ Unix 等待 SIGINT/SIGTERM,Windows 等待 Ctrl+C/Ctrl+Break;标准输入先 ## Android protect +Android JNI 类固定为 `io.github.yuandevteam.vole.NativeVole`,与宿主应用的包名独立。 +宿主提供如下 Java 声明(或等价的 Kotlin/JVM 声明),并打包匹配 ABI 的原生库: + +```java +package io.github.yuandevteam.vole; + +public final class NativeVole { + static { + System.loadLibrary("vole"); + } + + private NativeVole() {} + + public static native byte[] nativeInvoke(byte[] request); + public static native boolean nativeRegisterProtectController(Object controller); + public static native boolean nativeUnregisterProtectController(); +} +``` + +仅导出该命名空间下的三个 JNI 方法,不提供其它包名别名。宿主声明必须与原生库一致; +代码压缩和混淆必须保留该类、这些 native 方法以及 controller 的 `boolean protect(int)` +方法名。`nativeInvoke` 的请求与响应均为标准 UTF-8 JSON `byte[]`,由 JVM 管理, +不使用 C ABI 的 `VoleFree` 释放。 + Android TUN 通过 Invoke 之外的运行时本地回调注册: ```text @@ -385,11 +409,37 @@ ProtectFd(fd) -> bool - `getStartupTaskStatus` - `setStartupTaskEnabled` +### VPN profile 名称 + +`startVpn`、`getVpnStatus` 和 `stopVpn` 的 payload 都接受可选的 `profileName`。 +省略时使用 `Vole`;使用自定义名称的宿主必须在这三个方法中始终传入同一个值,包括 +前台重启后的查询。名称不属于业务 YAML、Session Snapshot 或 profile custom configuration, +不改变 Session token 的计算。 + +```json +{"bridgeVersion":3,"method":"getVpnStatus","payload":{"profileName":"Example VPN"}} +``` + +- 名称必须是 1–256 UTF-8 字节的字符串,不能有首尾 Unicode 空白或控制字符(含 NUL)。 + `null`、空串和其它 JSON 类型都拒绝,不 trim、不补默认值;其它系统名称限制仍由 Windows 返回错误。 +- 仅匹配当前 package family 内同名的 VPN profile,区分大小写且不做 Unicode normalization。 + 其它 package 的 profile 不会被接管或修改;Windows 名称冲突不会触发删除、重命名或回退。 +- 同包存在其它名称的活动 profile 时,三个方法均失败,而不是报告已断开或操作那个 profile; + 多个同名匹配同样失败。同包不同名且已断开的 profile 不参与本次操作。 +- 宿主应使用稳定名称。这不是多 profile 管理接口,不支持多会话并行、自动迁移、重命名或删除。 + 更换名称前必须用旧名称显式 `stopVpn` 并确认断开;使用新名称启动不会复用或删除旧名称的 profile。 + 名称选择不会转换旧 Session token 或解除现有 profile 配置校验。 +- `getEnvironment`、`getStartupTaskStatus` 和 `setStartupTaskEnabled` 不接受 `profileName`。 + StartupTask ID 仍固定为 `VoleStartup`,与 VPN profile 名称无关。 + +### 启动 payload + `startVpn` 的 payload 为: ```json { - "configYaml": "tun:\n enable: true\n...", + "profileName": "Example VPN", + "configYaml": "tun:\n enable: true\n mtu: 1400\n...", "networkSettings": { "ipv4Address": "192.168.3.1", "ipv6Address": "fd00::2", @@ -418,7 +468,7 @@ ProtectFd(fd) -> bool 桥接把 YAML、进程顺序、路径和参数发布为 `vole-session-v2:` Session Snapshot。参数引用的文件由调用方保持存在且不可变,Vole 不读取或摘要其内容。`getVpnStatus.data.snapshotToken` 返回该完整 Session token。 -桥接请求最大 1 MiB。它负责安装包身份、单一 VPN profile、不可变 Session Snapshot、连接/断开命令和系统 VPN 状态;Provider 负责激活 Session Host。桥接不公开 profile CRUD、内部文件路径、backend 描述、参数、PID、管道名称或 Snapshot 维护。数据包、Controller 流量查询、代理组查询/切换和业务生命周期不经过该 JSON 桥接。 +桥接请求最大 1 MiB。它负责安装包身份、按名称选择的 VPN profile、不可变 Session Snapshot、连接/断开命令和系统 VPN 状态;Provider 负责激活 Session Host。桥接不公开 profile CRUD、内部文件路径、backend 描述、参数、PID、管道名称或 Snapshot 维护。数据包、Controller 流量查询、代理组查询/切换和业务生命周期不经过该 JSON 桥接。 ## 编码与安全边界 @@ -428,3 +478,5 @@ ProtectFd(fd) -> bool - 嵌套 UDP 协议可以增加有界帧头,但解封装后的最终负载仍受该平台响应上限限制。 - 节点和代理组定义名共享大小写敏感的严格 UTF-8 命名空间;`DIRECT`、`REJECT` 和 `RULES` 不能用作定义名。 - Secret、password、UUID、REALITY key、short ID、目标地址和完整配置不得进入日志。 +- Apple Unified Logging 的 subsystem 固定为 `io.github.yuandevteam.vole`,category 为 + `vole`,不继承宿主应用的 bundle identifier;Console 或 `log stream` 应按该标识筛选。 diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index 5fe7e68..a700ba9 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -171,7 +171,7 @@ vole-windows-session-host.exe - Session Host 是 `windows.fullTrustProcess` extension,不显示在应用列表,也不注册 StartupTask 或 URI; - Provider 的 `windows.backgroundTasks` extension 显式使用 `windowsApp + appContainer`; - Provider activation class 来自 `vole.dll`; -- 同一 package 只维护一个 `Vole` VPN profile; +- 宿主为同一 package 使用一个稳定的 VPN profile 名称;`startVpn`、`getVpnStatus`、`stopVpn` 的可选 `profileName` 缺省为 `Vole`,匹配和改名边界见 [Invoke API](invoke-api.md#vpn-profile-名称)。同包仍只允许一个活动会话; - custom configuration 是最大 4 KiB 的严格 JSON,只含修订版 4、Session token、顶层 IPv6 开关、四个网络地址和完整 policy; - Session Snapshot 是 `LocalState/vole/windows/sessions/.json`,revision 2 覆盖完整 YAML、可选进程顺序、路径和参数;读取验证大小、普通文件、reparse point、规范 JSON、摘要和每个 executable。参数引用的文件由宿主保持存在且不可变,Vole 不读取其内容; - 活动 Session token、IPv6 开关、网络地址或 policy 不同时必须先显式 Stop,不能热切换; @@ -233,9 +233,9 @@ Session Host 每次连接新建一个进程,不常驻、不复用运行时, ## 启动顺序 -1. 前台宿主调用 `startVpn(configYaml, networkSettings, policy, sessionBackend?)`。 +1. 前台宿主调用 `startVpn(configYaml, networkSettings, policy, sessionBackend?, profileName?)`,后续查询和停止使用同一 profile 名称。 2. 桥接验证配置、四个地址、policy 和进程描述,发布不可变 Session Snapshot,并把解析后的顶层 IPv6 开关和 policy 写入 profile configuration。 -3. 桥接写入单一 VPN profile 并调用 `ConnectProfileAsync`;它不启动或持有 Session Host。 +3. 桥接按当前 package family 和请求的名称查找并写入 VPN profile,再调用 `ConnectProfileAsync`;它不接管其它 profile,也不启动或持有 Session Host。 4. Windows 激活 AppContainer Provider。 5. Provider 从 profile configuration 取得权威 token,选择物理网络绑定并准备基础资源。 6. Provider 清理陈旧会合记录,通过无参数 `FullTrustProcessLauncher` 激活 Session Host。 diff --git a/example/windows-uwp/README.md b/example/windows-uwp/README.md index 130148e..9fe4844 100644 --- a/example/windows-uwp/README.md +++ b/example/windows-uwp/README.md @@ -126,9 +126,14 @@ if (response != nullptr) { | 方法 | payload | 用途 | | --- | --- | --- | | `getEnvironment` | `{}` | 验证 package identity,返回 PFN 和 LocalState 路径 | -| `getVpnStatus` | `{}` | 查询同包唯一 profile | -| `startVpn` | `configYaml` + `networkSettings` + `policy` + 可选 `sessionBackend` | 发布 Session Snapshot、配置全局 VPN policy 并连接 | -| `stopVpn` | `{}` | 断开当前 profile | +| `getVpnStatus` | 可选 `profileName` | 查询同包指定名称的 profile | +| `startVpn` | `configYaml` + `networkSettings` + `policy` + 可选 `sessionBackend` / `profileName` | 发布 Session Snapshot、配置全局 VPN policy 并连接 | +| `stopVpn` | 可选 `profileName` | 断开同包指定名称的 profile | + +本 demo 省略 `profileName`,三个方法都操作默认名称 `Vole`。接入其它宿主时,可以在 +这三个请求的 payload 中统一增加 `"profileName":"Example VPN"`;重启后的状态查询 +也必须使用相同名称。此设置不是用户节点选择,也不会迁移或删除旧名称的 profile。 +完整校验、包隔离和更换名称约束见 [Invoke API](../../docs/invoke-api.md#vpn-profile-名称)。 桥接还提供 `getStartupTaskStatus` 和 `setStartupTaskEnabled`。本 demo 故意不声明 StartupTask,避免登录时启动一个无 UI 的命令行工具;产品需要该能力时,再声明 `xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"`、把 `desktop` 加入 `IgnorableNamespaces`,并在前台 `` 下增加: @@ -199,7 +204,7 @@ Vole 会校验 YAML、发布 `vole-session-v2:` 内容寻址 Session Snapshot, | capabilities | `internetClientServer`、`privateNetworkClientServer`、`runFullTrust`、`networkingVpnProvider` | | minimum desktop OS | `10.0.19042.0` | -可以修改 identity、publisher、版本、主 Application Id/EXE、显示名称、图标和 app execution alias。不要删除 Session Host/Provider extensions 或修改 `Vole.VpnBackgroundTask`。桥接还固定使用 profile 名 `Vole`,可选 StartupTask 固定使用 `VoleStartup`;两者都按 package family 隔离。 +可以修改 identity、publisher、版本、主 Application Id/EXE、显示名称、图标和 app execution alias。不要删除 Session Host/Provider extensions 或修改 `Vole.VpnBackgroundTask`。VPN profile 名称由桥接 payload 的 `profileName` 选择,缺省为 `Vole`;可选 StartupTask 仍固定使用 `VoleStartup`,不随 profile 名称变化。两者都按 package family 隔离。 三项 Vole 文件必须位于 package 根目录: diff --git a/include/vole_windows_uwp.h b/include/vole_windows_uwp.h index b3c72ee..cef2a91 100644 --- a/include/vole_windows_uwp.h +++ b/include/vole_windows_uwp.h @@ -11,7 +11,9 @@ extern "C" { * Only windows-uwp builds export this entry point. Windows package hosts use * a separate revision-3 JSON contract for package environment, VPN profile * lifecycle, immutable Session Snapshot publication, optional session backend - * processes, and StartupTask operations. The response has the same allocation + * processes, and StartupTask operations. The startVpn/getVpnStatus/stopVpn + * payloads accept an optional profileName (default: Vole); callers must use + * the same name for all three operations. The response has the same allocation * ownership as VoleInvoke and must be released with VoleFree. The calling * thread must either have no COM apartment initialized or already be * initialized as MTA; STA and ASTA callers are unsupported. diff --git a/scripts/tests/test_platform_namespaces.py b/scripts/tests/test_platform_namespaces.py new file mode 100644 index 0000000..fba742d --- /dev/null +++ b/scripts/tests/test_platform_namespaces.py @@ -0,0 +1,34 @@ +"""Guard platform namespace source contracts, not native loading or device behavior.""" + +import re +import unittest +from pathlib import Path + +CORE_DIR = Path(__file__).resolve().parents[2] +NAMESPACE = "io.github.yuandevteam.vole" + + +class PlatformNamespaceTests(unittest.TestCase): + def test_android_exports_only_the_canonical_jni_class(self): + source = (CORE_DIR / "src/ffi/android.rs").read_text(encoding="utf-8") + symbols = re.findall( + r'pub (?:unsafe )?extern "system" fn (Java_\w+)\s*\(', source + ) + prefix = "Java_" + NAMESPACE.replace(".", "_") + "_NativeVole_" + self.assertCountEqual( + symbols, + [ + prefix + "nativeInvoke", + prefix + "nativeRegisterProtectController", + prefix + "nativeUnregisterProtectController", + ], + ) + + def test_apple_logging_uses_the_canonical_identity(self): + source = (CORE_DIR / "src/platform/apple_logging.rs").read_text( + encoding="utf-8" + ) + self.assertRegex( + source, rf'(?m)^const SUBSYSTEM: &str = "{re.escape(NAMESPACE)}";$' + ) + self.assertRegex(source, r'(?m)^const CATEGORY: &str = "vole";$') diff --git a/src/ffi/android.rs b/src/ffi/android.rs index b1cddf9..bc9c592 100644 --- a/src/ffi/android.rs +++ b/src/ffi/android.rs @@ -170,7 +170,7 @@ pub unsafe extern "system" fn JNI_OnUnload(_vm: JavaVm, _reserved: *mut c_void) /// supplementary Unicode characters such as emoji. #[unsafe(no_mangle)] #[allow(non_snake_case)] -pub unsafe extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeInvoke( +pub unsafe extern "system" fn Java_io_github_yuandevteam_vole_NativeVole_nativeInvoke( env: JniEnv, _receiver: JObject, request: JByteArray, @@ -195,11 +195,11 @@ pub unsafe extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeInvok /// Registers a runtime-local controller with method `boolean protect(int fd)`. /// -/// `io.github.onexray.vole` is Vole's stable Android namespace. The dispatcher +/// `io.github.yuandevteam.vole` is Vole's stable Android namespace. The dispatcher /// accepts replacement whenever no instance currently holds the Android TUN lease. #[unsafe(no_mangle)] #[allow(non_snake_case)] -pub unsafe extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeRegisterProtectController( +pub unsafe extern "system" fn Java_io_github_yuandevteam_vole_NativeVole_nativeRegisterProtectController( env: JniEnv, _receiver: JObject, controller: JObject, @@ -217,7 +217,7 @@ pub unsafe extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeRegis /// Unregisters the Android controller while no TUN lease is held. #[unsafe(no_mangle)] #[allow(non_snake_case)] -pub extern "system" fn Java_io_github_onexray_vole_NativeVole_nativeUnregisterProtectController( +pub extern "system" fn Java_io_github_yuandevteam_vole_NativeVole_nativeUnregisterProtectController( _env: JniEnv, _receiver: JObject, ) -> JBoolean { diff --git a/src/invoke/mod.rs b/src/invoke/mod.rs index 68fe33c..4b4a7d3 100644 --- a/src/invoke/mod.rs +++ b/src/invoke/mod.rs @@ -23,7 +23,7 @@ use serde_json::{Value, json}; use tokio::sync::oneshot; use crate::{ - BUILD_IDENTITY, ENGINE, Lifecycle, LifecycleState, ResourceLimits, TunFraming, VoleError, + BUILD_IDENTITY, ENGINE, Lifecycle, LifecycleState, ResourceLimits, VoleError, config::Config, data_dir::DataDirectory, dialer::{Dialer, SocketProtector, SystemResolver}, @@ -38,7 +38,10 @@ use crate::{ target_os = "macos", target_os = "linux" ))] -use crate::platform::{TunFd, TunIo}; +use crate::{ + TunFraming, + platform::{TunFd, TunIo}, +}; #[cfg(any( target_os = "android", diff --git a/src/platform/apple_logging.rs b/src/platform/apple_logging.rs index 925f15c..24c24c0 100644 --- a/src/platform/apple_logging.rs +++ b/src/platform/apple_logging.rs @@ -22,7 +22,7 @@ use tracing::{ subscriber::Interest, }; -const SUBSYSTEM: &str = "io.github.onexray.vole"; +const SUBSYSTEM: &str = "io.github.yuandevteam.vole"; const CATEGORY: &str = "vole"; const MAX_EVENT_BYTES: usize = 2 * 1024; const TRUNCATION_MARKER: &str = "..."; @@ -195,8 +195,8 @@ fn os_log_level(level: &Level) -> OsLogLevel { { // Xcode does not reliably forward Default/Info messages from a Packet // Tunnel Extension unless it is attached as the debug executable. - // Match the existing Swift YGLog transport level while retaining the - // real tracing level in the formatted message. + // Use the Error transport level to keep extension diagnostics visible, + // while retaining the real tracing level in the formatted message. let _ = level; return OsLogLevel::Error; } diff --git a/src/windows/host.rs b/src/windows/host.rs index ae16cc6..8ca5491 100644 --- a/src/windows/host.rs +++ b/src/windows/host.rs @@ -34,7 +34,8 @@ use crate::{config::Config, platform::validate_packet_channel_config}; const BRIDGE_VERSION: u32 = 3; const MAX_REQUEST_BYTES: usize = 1024 * 1024; const MAX_ERROR_BYTES: usize = 4096; -const PROFILE_NAME: &str = "Vole"; +const DEFAULT_PROFILE_NAME: &str = "Vole"; +const MAX_PROFILE_NAME_BYTES: usize = 256; const STARTUP_TASK_ID: &str = "VoleStartup"; static COMMAND: Mutex<()> = Mutex::new(()); @@ -50,9 +51,44 @@ struct BridgeRequest { #[serde(deny_unknown_fields)] struct EmptyPayload {} +/// A host-selected Windows profile name, never a filesystem path or session ID. +struct ProfileName(String); + +impl Default for ProfileName { + fn default() -> Self { + Self(DEFAULT_PROFILE_NAME.to_owned()) + } +} + +impl<'de> Deserialize<'de> for ProfileName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let name = String::deserialize(deserializer)?; + if name.is_empty() + || name.len() > MAX_PROFILE_NAME_BYTES + || name.trim() != name + || name.chars().any(char::is_control) + { + return Err(serde::de::Error::custom("invalid Windows VPN profile name")); + } + Ok(Self(name)) + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProfilePayload { + #[serde(default)] + profile_name: ProfileName, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct StartPayload { + #[serde(default)] + profile_name: ProfileName, config_yaml: String, network_settings: WindowsNetworkSettings, policy: WindowsVpnPolicy, @@ -141,15 +177,9 @@ fn invoke_bytes(request: &[u8]) -> Result { decode_payload::(request.payload)?; get_environment() } - "getVpnStatus" => { - decode_payload::(request.payload)?; - get_vpn_status() - } + "getVpnStatus" => get_vpn_status(decode_payload(request.payload)?), "startVpn" => start_vpn(decode_payload(request.payload)?), - "stopVpn" => { - decode_payload::(request.payload)?; - stop_vpn() - } + "stopVpn" => stop_vpn(decode_payload(request.payload)?), "getStartupTaskStatus" => { decode_payload::(request.payload)?; get_startup_task_status() @@ -167,15 +197,16 @@ fn get_environment() -> Result { })) } -fn get_vpn_status() -> Result { +fn get_vpn_status(payload: ProfilePayload) -> Result { let environment = package_environment()?; let agent = VpnManagementAgent::new().map_err(display_error)?; - let profile = find_profile(&agent, &environment.family_name)?; + let profile = find_profile(&agent, &environment.family_name, &payload.profile_name.0)?; profile_status_data(profile.as_ref()) } fn start_vpn(payload: StartPayload) -> Result { let StartPayload { + profile_name, config_yaml, network_settings, policy, @@ -209,7 +240,7 @@ fn start_vpn(payload: StartPayload) -> Result { let always_on = profile_configuration.policy().always_on(); let token = snapshot.token(); let agent = VpnManagementAgent::new().map_err(display_error)?; - let existing = find_profile(&agent, &environment.family_name)?; + let existing = find_profile(&agent, &environment.family_name, &profile_name.0)?; if let Some(existing) = &existing { match existing.status { @@ -249,6 +280,7 @@ fn start_vpn(payload: StartPayload) -> Result { configure_profile( &profile, &environment.family_name, + &profile_name.0, &profile_configuration_json, always_on, )?; @@ -278,10 +310,11 @@ fn start_vpn(payload: StartPayload) -> Result { Ok(json!({"status": "connected", "snapshotToken": token})) } -fn stop_vpn() -> Result { +fn stop_vpn(payload: ProfilePayload) -> Result { let environment = package_environment()?; let agent = VpnManagementAgent::new().map_err(display_error)?; - let Some(profile) = find_profile(&agent, &environment.family_name)? else { + let Some(profile) = find_profile(&agent, &environment.family_name, &payload.profile_name.0)? + else { return Ok(json!({"status": "disconnected", "snapshotToken": null})); }; if profile.status == VpnManagementConnectionStatus::Disconnected { @@ -371,6 +404,7 @@ fn package_environment() -> Result { fn find_profile( agent: &VpnManagementAgent, family_name: &str, + profile_name: &str, ) -> Result, String> { let profiles = agent .GetProfilesAsync() @@ -386,14 +420,17 @@ fn find_profile( continue; } let status = plugin.ConnectionStatus().map_err(display_error)?; - if plugin.ProfileName().map_err(display_error)? != PROFILE_NAME { - if status != VpnManagementConnectionStatus::Disconnected { - return Err("unknown package-owned Windows VPN profile is active".to_owned()); - } + if !matches_profile_name( + profile_name, + &plugin.ProfileName().map_err(display_error)?.to_string(), + status, + )? { continue; } if found.is_some() { - return Err("multiple package-owned Vole VPN profiles exist".to_owned()); + return Err( + "multiple package-owned Windows VPN profiles match the requested name".to_owned(), + ); } found = Some(ProfileMatch { profile: plugin, @@ -403,6 +440,21 @@ fn find_profile( Ok(found) } +/// Only called for profiles whose package family has already been verified. +fn matches_profile_name( + requested: &str, + actual: &str, + status: VpnManagementConnectionStatus, +) -> Result { + if actual == requested { + return Ok(true); + } + if status != VpnManagementConnectionStatus::Disconnected { + return Err("another package-owned Windows VPN profile is active".to_owned()); + } + Ok(false) +} + fn plugin_profile( cast: WindowsResult, ) -> Result, String> { @@ -416,11 +468,12 @@ fn plugin_profile( fn configure_profile( profile: &VpnPlugInProfile, family_name: &str, + profile_name: &str, profile_configuration: &str, always_on: bool, ) -> Result<(), String> { profile - .SetProfileName(&PROFILE_NAME.into()) + .SetProfileName(&profile_name.into()) .and_then(|()| profile.SetVpnPluginPackageFamilyName(&family_name.into())) .and_then(|()| profile.SetCustomConfiguration(&profile_configuration.into())) .and_then(|()| profile.SetAlwaysOn(always_on)) @@ -550,6 +603,124 @@ mod tests { ); } + fn start_payload() -> Value { + json!({ + "configYaml": "tun:\n enable: true\n", + "networkSettings": { + "ipv4Address": "192.168.8.1", + "ipv6Address": "fd00:8::2", + "dnsIpv4Address": "223.5.5.5", + "dnsIpv6Address": "2400:3200::1" + }, + "policy": default_policy() + }) + } + + #[test] + fn profile_payload_defaults_and_custom_names_are_shared_by_lifecycle_commands() { + let status_or_stop: ProfilePayload = decode_payload(json!({})).unwrap(); + let start: StartPayload = decode_payload(start_payload()).unwrap(); + assert_eq!(status_or_stop.profile_name.0, "Vole"); + assert_eq!(start.profile_name.0, "Vole"); + + for name in [ + "Example VPN".to_owned(), + "示例 VPN".to_owned(), + "v".repeat(256), + "名".repeat(85), + ] { + let status_or_stop: ProfilePayload = + decode_payload(json!({"profileName": name})).unwrap(); + let mut input = start_payload(); + input["profileName"] = json!(name); + let start: StartPayload = decode_payload(input).unwrap(); + assert_eq!(status_or_stop.profile_name.0, name); + assert_eq!(start.profile_name.0, name); + } + } + + #[test] + fn profile_payload_rejects_invalid_names_without_echoing_input() { + for name in [ + Value::Null, + json!(false), + json!(123), + json!([]), + json!({}), + json!(""), + json!(" "), + json!(" Example VPN"), + json!("Example VPN\u{3000}"), + json!("Example\nVPN"), + json!("Example\0VPN"), + json!("Example\u{007f}VPN"), + json!("v".repeat(257)), + json!("名".repeat(86)), + ] { + let status_or_stop = decode_payload::(json!({"profileName": name})); + let mut input = start_payload(); + input["profileName"] = name; + let start = decode_payload::(input); + assert_eq!( + status_or_stop.err().as_deref(), + Some("invalid Windows host payload") + ); + assert_eq!(start.err().as_deref(), Some("invalid Windows host payload")); + } + } + + #[test] + fn profile_payload_keeps_unknown_fields_and_unrelated_methods_strict() { + assert!( + decode_payload::(json!({ + "profileName": "Example VPN", "unknown": true + })) + .is_err() + ); + let mut input = start_payload(); + input["profileName"] = json!("Example VPN"); + input["unknown"] = json!(true); + assert!(decode_payload::(input).is_err()); + assert!(decode_payload::(json!({"profileName": "Example VPN"})).is_err()); + assert!( + decode_payload::(json!({ + "enabled": true, "profileName": "Example VPN" + })) + .is_err() + ); + } + + #[test] + fn profile_lookup_matches_names_exactly_and_preserves_the_single_session_guard() { + for status in [ + VpnManagementConnectionStatus::Disconnected, + VpnManagementConnectionStatus::Connecting, + VpnManagementConnectionStatus::Connected, + VpnManagementConnectionStatus::Disconnecting, + ] { + assert!(matches_profile_name("Example VPN", "Example VPN", status).unwrap()); + for other in ["Vole", "example vpn", "Other VPN"] { + let result = matches_profile_name("Example VPN", other, status); + if status == VpnManagementConnectionStatus::Disconnected { + assert!(!result.unwrap()); + } else { + assert_eq!( + result.unwrap_err(), + "another package-owned Windows VPN profile is active" + ); + } + } + } + assert!( + !matches_profile_name( + "Café", + "Cafe\u{0301}", + VpnManagementConnectionStatus::Disconnected + ) + .unwrap() + ); + } + #[test] fn start_payload_accepts_only_strict_external_network_settings() { let payload: StartPayload = decode_payload(json!({ @@ -691,8 +862,14 @@ mod tests { let _winrt = WinRtGuard::enter().unwrap(); let profile = VpnPlugInProfile::new().unwrap(); - configure_profile(&profile, "example.family", "{}", true).unwrap(); + configure_profile(&profile, "example.family", "示例 VPN", "{}", true).unwrap(); + assert_eq!(profile.ProfileName().unwrap(), "示例 VPN"); + assert_eq!( + profile.VpnPluginPackageFamilyName().unwrap(), + "example.family" + ); + assert_eq!(profile.CustomConfiguration().unwrap(), "{}"); assert!(profile.AlwaysOn().unwrap()); } diff --git a/tests/README.md b/tests/README.md index f34c5af..fae33e3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,13 +19,24 @@ uv run --project scripts --locked ruff check scripts uv run --project scripts --locked ruff format --check scripts ``` +Windows UWP bridge 的名称准入与匹配使用独立过滤器;下列测试仅处理 JSON、匹配策略 +和未注册的内存 `VpnPlugInProfile` 对象,不创建系统 VPN profile 或启动连接: + +```sh +cargo test --locked --no-default-features --features ffi,windows-uwp --lib windows::host::tests:: +``` + +在 Windows 上,`windows-uwp` 与 `windows-wintun` 互斥,编译与 Clippy 应明确选择 +所测后端而非 `--all-features`。名称选择仍需已安装包验证,不以对象级测试代替。 + 宿主只执行明确的纯内存过滤器;全目标使用 `--no-run`。 `invoke::tests::` 含监听器用例,不可整体在宿主执行;可执行过滤器以 [Tests workflow](../.github/workflows/test.yml) 为准。 协议独立向量见 [protocols](protocols/README.md),ClientHello 输入见 [fingerprints](fingerprints/README.md)。 脚本测试保留平台文件输出、Windows 后端选择、Android ABI/runtime、Apple 切片、 -真实归档读写。外部编译命令由夹具替代,原生编译由 CI 平台矩阵执行。 +真实归档读写,以及 Android JNI 导出与 Apple 日志命名空间的源码契约检查。 +外部编译命令由夹具替代,原生编译由 CI 平台矩阵执行;源码检查不替代 JNI 加载或设备验证。 CI 的核心回归分别使用 Debug 和 `ci-release`;后者继承 Release 优化并关闭 LTO, 正式产物使用标准 `release`。Quality 负责格式、Clippy、feature 编译和依赖来源检查。 From 825ddba52a555b4e2194564ef2302f8d2eacd346 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 11:31:02 +0800 Subject: [PATCH 2/3] Keep Windows host MTA alive across bridge calls Retain one process-lifetime MTA usage reference for cached WinRT factories while balancing each caller-thread initialization. Preserve STA rejection and prevent guards from moving between threads. Add isolated native DLL regressions for repeated calls, caller MTA release, worker handoff and STA rejection, and run them before UWP artifact upload. --- .github/workflows/release.yml | 14 +++ Cargo.toml | 1 + docs/invoke-api.md | 2 +- docs/windows-vpn.md | 17 ++- example/windows-uwp/README.md | 1 + include/vole_windows_uwp.h | 7 +- scripts/check_windows_com.py | 202 ++++++++++++++++++++++++++++++++++ src/windows/host.rs | 29 ++++- tests/README.md | 12 ++ 9 files changed, 277 insertions(+), 8 deletions(-) create mode 100644 scripts/check_windows_com.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c879354..dec33e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -244,6 +244,20 @@ jobs: if [[ -n "$VOLE_RELEASE_TAG" ]]; then args+=(--tag "$VOLE_RELEASE_TAG"); fi uv run --project scripts --locked python -m vole_scripts.release "${args[@]}" test -f "dist/release/$VOLE_GROUP/$VOLE_ARCHIVE" + - name: Check unpackaged Windows COM lifecycle + if: matrix.platform == 'windows' && matrix.backend == 'uwp' + shell: bash + env: + VOLE_TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + case "$VOLE_TARGET" in + x86_64-pc-windows-msvc) architecture=x64 ;; + aarch64-pc-windows-msvc) architecture=arm64 ;; + *) exit 1 ;; + esac + uv run --project scripts --locked python scripts/check_windows_com.py \ + --dll "dist/windows/$architecture/uwp/vole.dll" - name: Upload release archive uses: actions/upload-artifact@v7 with: diff --git a/Cargo.toml b/Cargo.toml index 7f50d95..0fa6d8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -164,6 +164,7 @@ windows = { version = "=0.62.2", optional = true, features = [ "Win32_Networking_WinSock", "Win32_Security", "Win32_Security_Isolation", + "Win32_System_Com", "Win32_System_JobObjects", "Win32_System_RemoteDesktop", "Win32_System_Threading", diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 87aad9f..2cf865b 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -19,7 +19,7 @@ void VoleFree(char *response); - 业务运行时线程不能重入 Invoke;Debug 和 Release 构建都立即返回失败 JSON,包括 `version` 等只读请求。 - 请求正文、响应正文、完整配置、UUID、密钥、short ID 和凭据不得写入日志。 - UWP 宿主包含 `vole_windows_uwp.h`(同时引入 `vole.h`),其中声明 `VoleWindowsVpnInvoke`。该头文件和独立的 Windows 安装包桥接接口仅由 `windows-uwp` 构建提供。 -- `VoleWindowsVpnInvoke` 当前在调用线程上初始化 MTA;调用线程必须尚未初始化 COM,或已经是 MTA。STA/ASTA 调用不受支持。 +- `VoleWindowsVpnInvoke` 接受未显式初始化 COM 或已是 MTA 的调用线程;STA/ASTA 调用失败且不改变其 apartment。库配对管理每次调用的初始化,并为静态 WinRT factory 缓存保留一份进程寿命的 MTA 引用;宿主无需额外持有 MTA,短生命周期工作线程可串行重复调用。返回后未显式初始化的线程可能处于 implicit MTA,不遗留逐次显式初始化计数;完整边界见 [COM / WinRT 生命周期](windows-vpn.md#com--winrt-生命周期)。 ## 请求与响应 diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index a700ba9..cf7343c 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -21,7 +21,20 @@ tun-rs 的 Wintun 后端,也不依赖外置 `wintun.dll`。Wintun 的 interrup 未安装的普通桌面进程没有 package identity,Windows VPN 桥接会失败关闭。 -`VoleWindowsVpnInvoke` 当前在调用线程上初始化 MTA。调用线程必须尚未初始化 COM,或已经是 MTA;STA/ASTA 调用不受支持。 +### COM / WinRT 生命周期 + +`VoleWindowsVpnInvoke` 在调用线程上配对执行 `RoInitialize(MTA)` / `RoUninitialize`。 +调用线程可以尚未显式初始化 COM,或已经是 MTA;STA/ASTA 调用失败,不改变调用方的 apartment。 + +桥接首次成功进入 MTA 时,通过 `CoIncrementMTAUsage` 保留一份进程寿命的 MTA 引用, +与 `windows-rs` 的静态 agile factory 缓存同寿命。即使调用线程退出、调用方释放自己的 +MTA,或两次请求之间没有显式 MTA 线程,缓存也不会跨越 MTA 拆卸后被再次使用。 +这是一份固定引用,不随请求增长;不在逐次返回或 DLL/process teardown 时释放, +也不在 loader lock 内执行 COM 清理。获取失败仍配对释放本次初始化,并返回失败。 + +宿主无需额外持有 MTA,可以从不同的短生命周期工作线程串行调用。返回后,原本未显式 +初始化的线程可能属于进程的 implicit MTA;桥接不会遗留该线程的显式初始化计数。 +命令仍不允许重叠,不增加工作队列,也不改变包身份、profile 或会话生命周期。 ## `IVpnPlugIn` 回调 @@ -347,3 +360,5 @@ Stopped { version, packetCounters } - [`VpnManagementAgent`](https://learn.microsoft.com/uwp/api/windows.networking.vpn.vpnmanagementagent) - [`FullTrustProcessLauncher`](https://learn.microsoft.com/uwp/api/windows.applicationmodel.fulltrustprocesslauncher) - [Package identity](https://learn.microsoft.com/windows/apps/desktop/modernize/package-identity-overview) +- [`RoInitialize`](https://learn.microsoft.com/windows/win32/api/roapi/nf-roapi-roinitialize) +- [`CoIncrementMTAUsage`](https://learn.microsoft.com/windows/win32/api/combaseapi/nf-combaseapi-coincrementmtausage) diff --git a/example/windows-uwp/README.md b/example/windows-uwp/README.md index 9fe4844..af643a9 100644 --- a/example/windows-uwp/README.md +++ b/example/windows-uwp/README.md @@ -117,6 +117,7 @@ if (response != nullptr) { - revision 固定为 `bridgeVersion: 3`; - DTO 严格拒绝未知字段; - 返回内存必须由同一份 `vole.dll` 的 `VoleFree` 释放; +- 调用线程可尚未显式初始化 COM 或已经是 MTA,不能是 STA/ASTA。库为静态 WinRT factory 缓存保留一份进程寿命的 MTA 引用,并配对每次调用的初始化;宿主无需自行持有 MTA,可从不同的短生命周期工作线程串行调用,见 [COM 生命周期](../../docs/windows-vpn.md#com--winrt-生命周期); - 桥接命令不能重叠;真实前台应在单进程内串行调用,本命令行 demo 额外用 session-local named mutex 串行化多个 alias 进程; - 调用进程必须具有当前 MSIX package identity;unpackaged EXE 会失败关闭; - 不要从 Provider 回调、Session Host 或 AppContainer UI 重入该接口。 diff --git a/include/vole_windows_uwp.h b/include/vole_windows_uwp.h index cef2a91..0ff8537 100644 --- a/include/vole_windows_uwp.h +++ b/include/vole_windows_uwp.h @@ -16,7 +16,12 @@ extern "C" { * the same name for all three operations. The response has the same allocation * ownership as VoleInvoke and must be released with VoleFree. The calling * thread must either have no COM apartment initialized or already be - * initialized as MTA; STA and ASTA callers are unsupported. + * initialized as MTA; STA and ASTA callers are unsupported. Each call balances + * its own COM initialization. The library retains one process-lifetime MTA + * reference for cached WinRT factories, so callers need not retain an MTA + * between requests and may use different short-lived threads serially. A + * previously uninitialized thread may belong to the implicit MTA on return; + * no per-call explicit initialization is left on the calling thread. */ #ifdef _WIN32 char *VoleWindowsVpnInvoke(const char *request_json); diff --git a/scripts/check_windows_com.py b/scripts/check_windows_com.py new file mode 100644 index 0000000..eaf06d3 --- /dev/null +++ b/scripts/check_windows_com.py @@ -0,0 +1,202 @@ +"""Unpackaged DLL regression: COM lifetime only, without VPN or network access.""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import subprocess +import sys +import threading +from pathlib import Path + +CASES = ("uninitialized", "caller-mta", "worker-handoff", "sta-rejection") +CO_E_NOTINITIALIZED = ctypes.c_int32(0x800401F0).value +IMPLICIT_MTA = (0, 1, 1) +EXPLICIT_MTA = (0, 1, 0) +IDENTITY_FAILURE = { + "success": False, + "data": None, + "error": "Windows package identity is required", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def bind(library, name, result_type, argument_types): + function = getattr(library, name) + function.restype = result_type + function.argtypes = argument_types + return function + + +class Bridge: + def __init__(self, dll: Path): + kernel = ctypes.WinDLL("kernel32.dll") + bind(kernel, "SetErrorMode", ctypes.c_uint32, [ctypes.c_uint32])(0x8003) + package_name = bind( + kernel, + "GetCurrentPackageFullName", + ctypes.c_int32, + [ctypes.POINTER(ctypes.c_uint32), ctypes.POINTER(ctypes.c_uint16)], + ) + length = ctypes.c_uint32() + require( + package_name(ctypes.byref(length), None) == 15700, + "requires an unpackaged process; refusing to access package data or VPNs", + ) + com = ctypes.WinDLL("combase.dll") + self.initialize = bind(com, "RoInitialize", ctypes.c_int32, [ctypes.c_uint32]) + self.uninitialize = bind(com, "RoUninitialize", None, []) + self.get_apartment = bind( + com, + "CoGetApartmentType", + ctypes.c_int32, + [ctypes.POINTER(ctypes.c_int32), ctypes.POINTER(ctypes.c_int32)], + ) + library = ctypes.CDLL(str(dll)) + self.invoke = bind( + library, "VoleWindowsVpnInvoke", ctypes.c_void_p, [ctypes.c_char_p] + ) + self.free = bind(library, "VoleFree", None, [ctypes.c_void_p]) + self.calls = 0 + + def apartment(self) -> tuple[int, int, int]: + kind = ctypes.c_int32() + qualifier = ctypes.c_int32() + result = self.get_apartment(ctypes.byref(kind), ctypes.byref(qualifier)) + return result, kind.value, qualifier.value + + def query(self, method: str) -> dict: + request = json.dumps({"bridgeVersion": 3, "method": method, "payload": {}}) + pointer = self.invoke(request.encode("utf-8")) + require(pointer is not None, "bridge returned a null response") + try: + response = json.loads(ctypes.string_at(pointer).decode("utf-8")) + finally: + self.free(pointer) + self.calls += 1 + return response + + def repeat(self, count: int = 64, apartment=IMPLICIT_MTA) -> None: + for _ in range(count): + for method in ("getEnvironment", "getVpnStatus"): + require( + self.query(method) == IDENTITY_FAILURE, "expected identity error" + ) + require( + self.apartment() == apartment, + "per-call COM initialization was not balanced or MTA was torn down", + ) + + def reject_sta(self) -> None: + require(self.initialize(0) >= 0, "cannot initialize the test STA") + try: + before = self.apartment() + require(before[0] == 0 and before[1] in (0, 3), "expected STA or main STA") + for method in ("getEnvironment", "getVpnStatus"): + response = self.query(method) + require( + response["success"] is False + and response["data"] is None + and "80010106" in response["error"], + "STA must return RPC_E_CHANGED_MODE without executing the request", + ) + require(self.apartment() == before, "bridge changed the caller's STA") + finally: + self.uninitialize() + + +def run_case(dll: Path, case: str) -> None: + bridge = Bridge(dll) + require( + bridge.apartment()[0] == CO_E_NOTINITIALIZED, + "each regression must start without a preinitialized COM apartment", + ) + if case == "uninitialized": + bridge.repeat() + elif case == "caller-mta": + require(bridge.initialize(1) >= 0, "cannot initialize the test MTA") + try: + bridge.repeat(apartment=EXPLICIT_MTA) + finally: + bridge.uninitialize() + # The caller can release its own reference without invalidating factories. + bridge.repeat() + elif case == "worker-handoff": + # Each short-lived worker exits before the next is created. Neither the + # main thread nor the caller holds an explicit MTA across requests. + for index in range(32): + errors: list[BaseException] = [] + + def worker(first=index == 0, errors=errors): + try: + if first: + require( + bridge.apartment()[0] == CO_E_NOTINITIALIZED, + "first worker must start without COM initialization", + ) + else: + require( + bridge.apartment() == IMPLICIT_MTA, "MTA did not survive" + ) + bridge.repeat(2) + except BaseException as error: + errors.append(error) + + thread = threading.Thread(target=worker, daemon=True) + thread.start() + thread.join(timeout=10) + require(not thread.is_alive(), "worker did not complete within 10 seconds") + if errors: + raise errors[0] + bridge.repeat(2) + elif case == "sta-rejection": + bridge.reject_sta() + require( + bridge.apartment()[0] == CO_E_NOTINITIALIZED, + "a rejected STA call must not retain an MTA or unbalance the caller", + ) + bridge.repeat(2) + bridge.reject_sta() + bridge.repeat(2) + bridge.free(None) + print(f"PASS {case}: {bridge.calls} responses released; no VPN access", flush=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--dll", required=True, type=Path) + parser.add_argument("--case", choices=CASES, help=argparse.SUPPRESS) + arguments = parser.parse_args() + if sys.platform != "win32": + parser.error( + "requires native Windows and a matching-architecture Python and DLL" + ) + dll = arguments.dll.resolve(strict=True) + if arguments.case: + run_case(dll, arguments.case) + return + # A fresh process per case prevents other tests or initialized factories from + # hiding the first-call/last-MTA-thread bug. Timeouts also bound native hangs. + for case in CASES: + subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve()), + "--dll", + str(dll), + "--case", + case, + ], + check=True, + timeout=30, + ) + print("PASS all four isolated Windows COM lifecycle regressions", flush=True) + + +if __name__ == "__main__": + main() diff --git a/src/windows/host.rs b/src/windows/host.rs index 8ca5491..c723223 100644 --- a/src/windows/host.rs +++ b/src/windows/host.rs @@ -1,5 +1,6 @@ use std::{ ffi::{CString, c_char}, + marker::PhantomData, panic::{AssertUnwindSafe, catch_unwind}, path::PathBuf, ptr, @@ -18,7 +19,10 @@ use windows::{ Storage::ApplicationData, Win32::{ Foundation::E_NOINTERFACE, - System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize, RoUninitialize}, + System::{ + Com::CoIncrementMTAUsage, + WinRT::{RO_INIT_MULTITHREADED, RoInitialize, RoUninitialize}, + }, }, core::{Interface as _, Result as WindowsResult}, }; @@ -38,6 +42,11 @@ const DEFAULT_PROFILE_NAME: &str = "Vole"; const MAX_PROFILE_NAME_BYTES: usize = 256; const STARTUP_TASK_ID: &str = "VoleStartup"; static COMMAND: Mutex<()> = Mutex::new(()); +// windows-rs caches agile WinRT factories for process lifetime. Keep one opaque +// MTA usage cookie for the same lifetime, including gaps between caller threads. +// Never decrement it per request or during DLL/process teardown: cached factories +// could still be used, and COM shutdown under the loader lock is unsupported. +static MTA_USAGE: Mutex> = Mutex::new(None); #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] @@ -122,13 +131,23 @@ struct ProfileMatch { status: VpnManagementConnectionStatus, } -struct WinRtGuard; +// The per-call initialization must be released on its original thread. +struct WinRtGuard(PhantomData<*mut ()>); impl WinRtGuard { fn enter() -> Result { - unsafe { RoInitialize(RO_INIT_MULTITHREADED) } - .map(|()| Self) - .map_err(display_error) + // Reject STA/ASTA before acquiring the process-wide MTA reference. + unsafe { RoInitialize(RO_INIT_MULTITHREADED) }.map_err(display_error)?; + let guard = Self(PhantomData); + let mut cookie = MTA_USAGE + .lock() + .map_err(|_| "Windows host MTA lifetime lock is poisoned".to_owned())?; + if cookie.is_none() { + // This reference outlives all per-call guards. On failure the guard + // still balances RoInitialize, and a later call can retry retention. + *cookie = Some(unsafe { CoIncrementMTAUsage() }.map_err(display_error)?.0 as usize); + } + Ok(guard) } } diff --git a/tests/README.md b/tests/README.md index fae33e3..1579c2b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -26,6 +26,18 @@ Windows UWP bridge 的名称准入与匹配使用独立过滤器;下列测试 cargo test --locked --no-default-features --features ffi,windows-uwp --lib windows::host::tests:: ``` +构建 UWP DLL 后,可在未打包的 Windows 进程中执行真实 C ABI 的 COM 生命周期回归: + +```sh +uv run --project scripts --locked python scripts/check_windows_com.py --dll dist/windows/arm64/uwp/vole.dll +``` + +x64 使用 `dist/windows/x64/uwp/vole.dll`;Python 与 DLL 必须同架构。四个独立子进程 +覆盖未初始化线程连续查询、调用方 MTA 的保留与释放、短线程退出/切换和 STA 拒绝, +同时检查显式初始化计数与 `VoleFree` 响应所有权。测试先用 Win32 确认没有包身份, +仅查询环境/状态并断言包身份错误,不读取 VPN profile、启动连接或访问网络。 +父进程为每个用例设置超时。UWP 发布构建在上传归档前执行这些检查;通过不代表包内验收。 + 在 Windows 上,`windows-uwp` 与 `windows-wintun` 互斥,编译与 Clippy 应明确选择 所测后端而非 `--all-features`。名称选择仍需已安装包验证,不以对象级测试代替。 From 273f8447ec5df8b8582b14e3f49a42a19beb66ff Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 14:53:02 +0800 Subject: [PATCH 3/3] Use setup-python and native Windows toolchains in CI --- .github/workflows/release.yml | 62 ++++++++------------ .github/workflows/test.yml | 15 ++--- scripts/README.md | 11 +++- scripts/src/vole_scripts/builds.py | 16 +++++- scripts/tests/test_builds.py | 91 +++++++++++++++++++++++++++++- tests/README.md | 9 ++- 6 files changed, 152 insertions(+), 52 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dec33e1..06aabaa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,8 @@ concurrency: cancel-in-progress: false env: + UV_PYTHON_PREFERENCE: only-system + UV_PYTHON_DOWNLOADS: never # Reusable calls inherit the caller's event and workflow context. VOLE_RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_type == 'tag' && github.workflow_ref == format('{0}/.github/workflows/release.yml@{1}', github.repository, github.ref) && github.ref_name || '' }} VOLE_RELEASE_REPOSITORY: ${{ github.repository }} @@ -72,16 +74,14 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true + architecture: ${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }} - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - name: Linux native dependencies if: matrix.platform == 'linux' run: | @@ -188,16 +188,14 @@ jobs: with: toolchain: stable targets: ${{ matrix.targets }} + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true + architecture: ${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }} - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - name: Locked source run: cargo fetch --locked - name: Linux native dependencies @@ -256,8 +254,7 @@ jobs: aarch64-pc-windows-msvc) architecture=arm64 ;; *) exit 1 ;; esac - uv run --project scripts --locked python scripts/check_windows_com.py \ - --dll "dist/windows/$architecture/uwp/vole.dll" + python scripts/check_windows_com.py --dll "dist/windows/$architecture/uwp/vole.dll" - name: Upload release archive uses: actions/upload-artifact@v7 with: @@ -294,16 +291,13 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: targets: ${{ matrix.target }} + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - name: Cache native Rust dependencies uses: Swatinem/rust-cache@v2 with: @@ -337,16 +331,13 @@ jobs: - uses: actions/checkout@v7 with: persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - uses: actions/download-artifact@v8 with: pattern: vole-apple-*-${{ github.sha }} @@ -373,16 +364,13 @@ jobs: with: fetch-depth: 0 persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - uses: actions/download-artifact@v8 with: pattern: vole-release-build-*-${{ github.sha }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d6c985..ddec080 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,6 +12,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + UV_PYTHON_PREFERENCE: only-system + UV_PYTHON_DOWNLOADS: never + jobs: client-contracts: name: ${{ matrix.name }} @@ -33,16 +37,13 @@ jobs: with: toolchain: stable components: rustfmt, clippy + - uses: actions/setup-python@v7 + with: + python-version: "3.x" + check-latest: true - uses: astral-sh/setup-uv@v10.2.0 with: version: "0.12" - - name: Install latest stable Python 3 - shell: bash - env: - UV_PYTHON_INSTALL_DIR: ${{ runner.temp }}/python - run: | - uv python install 3 --no-bin --no-registry - echo "UV_PYTHON=$(uv python find --managed-python --no-project 3)" >> "$GITHUB_ENV" - name: Cache Rust dependencies uses: Swatinem/rust-cache@v2 with: diff --git a/scripts/README.md b/scripts/README.md index 03d5a52..ea0e196 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -15,6 +15,10 @@ uv run --project scripts --locked vole-scripts build windows --backend uwp CLI 默认构建当前宿主的 Release,Windows 固定 Wintun;Windows FFI 默认 UWP。 脚本输出产物路径,编译失败直接返回失败。 +Windows 的构建和验证只允许宿主原生架构:x64 使用 x64 Python、MSVC 与产物, +ARM64 使用 ARM64 Python、MSVC 与产物,不跨架构编译或通过模拟运行验证。 +Windows 需先安装本架构的 Python 并加入 `PATH`;为上述 `uv run` 增加 `--python python` +可明确使用该解释器。脚本拒绝非原生 Python 和不同架构的 Windows 目标。 | 目标 | 工具链与输出 | | --- | --- | @@ -34,7 +38,12 @@ Android 优先使用 `ANDROID_NDK_HOME`,否则在 `ANDROID_HOME/ndk` 中选择 [Release workflow](../.github/workflows/release.yml) 在 `vX.Y.Z` tag push 时构建并发布, tag 必须匹配 Cargo 版本。PR 使用同一构建矩阵,全部构建成功后汇总十四个归档。 -CLI 构建后执行 `-h/-v/-t` 检查。 +CI 中的 Python 均由 `actions/setup-python` 安装,选择最新稳定 Python 3;uv 仅使用系统解释器, +禁止自动下载 Python,不使用脚本安装或特定解释器发行版请求。 +CLI 构建后执行 `-h/-v/-t` 检查。Windows CLI、Wintun FFI、UWP FFI 分别使用独立的 +x64/ARM64 job,每个 job 的 runner、Python、工具链和验证产物必须同架构; +UWP 的 COM 回归只加载当前 job 构建的 DLL。ARM64 MSVC 使用原生 `vcvarsall arm64`, +不使用 `amd64_arm64` 交叉工具链。 Apple FFI 的六个 Rust 目标分别在独立 job 并行编译并缓存;`FFI Apple` 等待全部目标 成功后合并 macOS 双架构、生成五切片 XCFramework 并打包。 CI 的依赖来源检查见 [TLS 依赖](../docs/tls-dependencies.md)。 diff --git a/scripts/src/vole_scripts/builds.py b/scripts/src/vole_scripts/builds.py index f1b6b61..1f30556 100644 --- a/scripts/src/vole_scripts/builds.py +++ b/scripts/src/vole_scripts/builds.py @@ -6,6 +6,7 @@ import re import shutil import subprocess +import sysconfig import tempfile from pathlib import Path @@ -452,14 +453,23 @@ def _windows_architecture() -> str: ) as key: processor = str(winreg.QueryValueEx(key, "PROCESSOR_ARCHITECTURE")[0]).lower() try: - return {"amd64": "x64", "arm64": "arm64"}[processor] + architecture = {"amd64": "x64", "arm64": "arm64"}[processor] except KeyError as error: raise RuntimeError( f"unsupported native Windows processor architecture: {processor}" ) from error + python_platform = {"x64": "win-amd64", "arm64": "win-arm64"}[architecture] + if sysconfig.get_platform() != python_platform: + raise RuntimeError( + f"Windows {architecture} builds require native {architecture} Python" + ) + return architecture def _windows_msvc_environment(architecture: str) -> dict[str, str]: + if architecture != _windows_architecture(): + raise ValueError("Windows builds require the native host architecture") + component = "ARM64" if architecture == "arm64" else "x86.x64" program_files = os.environ.get("PROGRAMFILES(X86)") if not program_files: raise RuntimeError("ProgramFiles(x86) is unavailable") @@ -473,7 +483,7 @@ def _windows_msvc_environment(architecture: str) -> dict[str, str]: "-products", "*", "-requires", - "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + f"Microsoft.VisualStudio.Component.VC.Tools.{component}", "-find", r"VC\Auxiliary\Build\vcvarsall.bat", ], @@ -484,7 +494,7 @@ def _windows_msvc_environment(architecture: str) -> dict[str, str]: vcvars = next((line.strip() for line in result.stdout.splitlines() if line), None) if not vcvars: raise RuntimeError("Visual Studio C++ tools were not found") - vc_target = "amd64_arm64" if architecture == "arm64" else "amd64" + vc_target = "arm64" if architecture == "arm64" else "amd64" with tempfile.TemporaryDirectory(prefix="vole-msvc-") as directory: command = Path(directory) / "environment.cmd" command.write_bytes( diff --git a/scripts/tests/test_builds.py b/scripts/tests/test_builds.py index d140efd..49b9466 100644 --- a/scripts/tests/test_builds.py +++ b/scripts/tests/test_builds.py @@ -4,7 +4,7 @@ import tempfile import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch from vole_scripts import builds @@ -98,6 +98,95 @@ def test_windows_cli_and_ffi_select_their_backends(self): ) self.assertEqual("--bins" in command, backend == "uwp") + def test_windows_requires_native_python(self): + registry = MagicMock() + with patch.dict("sys.modules", {"winreg": registry}): + for processor, architecture, native_python in ( + ("AMD64", "x64", "win-amd64"), + ("ARM64", "arm64", "win-arm64"), + ): + registry.QueryValueEx.return_value = (processor, 0) + for python_platform in ("win-amd64", "win-arm64", "win32"): + with ( + self.subTest(host=processor, python=python_platform), + patch.object( + builds.sysconfig, + "get_platform", + return_value=python_platform, + ), + ): + if python_platform == native_python: + self.assertEqual( + builds._windows_architecture(), architecture + ) + else: + with self.assertRaisesRegex( + RuntimeError, "native .* Python" + ): + builds._windows_architecture() + + def test_windows_cli_rejects_cross_architecture_before_compilation(self): + for host, target in ( + ("arm64", "x86_64-pc-windows-msvc"), + ("x64", "aarch64-pc-windows-msvc"), + ): + with ( + self.subTest(host=host), + patch.object(builds.platform, "system", return_value="Windows"), + patch.object(builds, "_windows_architecture", return_value=host), + patch.object(builds.subprocess, "run") as run, + self.assertRaisesRegex(ValueError, "native host architecture"), + ): + builds.build_cli(target) + run.assert_not_called() + self.assertEqual(self.commands, []) + + def test_windows_msvc_selects_native_host_tools(self): + for architecture, vc_target, component in ( + ("x64", "amd64", "x86.x64"), + ("arm64", "arm64", "ARM64"), + ): + + def configure( + command, + *, + architecture=architecture, + vc_target=vc_target, + component=component, + **kwargs, + ): + if "-find" in command: + self.assertIn( + f"Microsoft.VisualStudio.Component.VC.Tools.{component}", + command, + ) + output = "C:\\VS\\vcvarsall.bat\n" + else: + script = Path(command[-1]).read_text() + self.assertIn( + f'call "C:\\VS\\vcvarsall.bat" {vc_target} >nul', script + ) + output = f"Path=tools\nVSCMD_ARG_HOST_ARCH={architecture}\n" + return builds.subprocess.CompletedProcess(command, 0, stdout=output) + + with ( + self.subTest(architecture=architecture), + patch.dict(os.environ, {"PROGRAMFILES(X86)": str(self.root)}), + patch.object( + builds, "_windows_architecture", return_value=architecture + ), + patch.object(builds.subprocess, "run", side_effect=configure), + patch.object( + builds.shutil, + "which", + side_effect=lambda name, **kw: f"tools/{name}", + ), + ): + env = builds._windows_msvc_environment(architecture) + self.assertEqual(env["VSCMD_ARG_HOST_ARCH"], architecture) + if architecture == "arm64": + self.assertEqual(env["CC_aarch64_pc_windows_msvc"], "tools/clang-cl") + def test_android_uses_matching_api_and_cpp_runtime(self): toolchain = self.root / "ndk/toolchain" for target, abi in ( diff --git a/tests/README.md b/tests/README.md index 1579c2b..d7112bb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -29,11 +29,14 @@ cargo test --locked --no-default-features --features ffi,windows-uwp --lib windo 构建 UWP DLL 后,可在未打包的 Windows 进程中执行真实 C ABI 的 COM 生命周期回归: ```sh -uv run --project scripts --locked python scripts/check_windows_com.py --dll dist/windows/arm64/uwp/vole.dll +python scripts/check_windows_com.py --dll dist/windows/arm64/uwp/vole.dll ``` -x64 使用 `dist/windows/x64/uwp/vole.dll`;Python 与 DLL 必须同架构。四个独立子进程 -覆盖未初始化线程连续查询、调用方 MTA 的保留与释放、短线程退出/切换和 STA 拒绝, +此命令只在原生 Windows ARM64 执行。原生 x64 主机使用本机的 Python 和 +`dist/windows/x64/uwp/vole.dll`。宿主、Python 和 DLL 必须同架构;不得通过模拟运行跨架构验证。 +该脚本只需 Python 标准库。CI 的各个 Windows job 通过 `actions/setup-python` 安装本架构的 +Python,直接运行此检查,仅验证各自构建的产物。 +四个独立子进程覆盖未初始化线程连续查询、调用方 MTA 的保留与释放、短线程退出/切换和 STA 拒绝, 同时检查显式初始化计数与 `VoleFree` 响应所有权。测试先用 Win32 确认没有包身份, 仅查询环境/状态并断言包身份错误,不读取 VPN profile、启动连接或访问网络。 父进程为每个用例设置超时。UWP 发布构建在上传归档前执行这些检查;通过不代表包内验收。