mirror of
https://github.com/YuanDevTeam/Vole.git
synced 2026-11-07 00:05:27 +00:00
* 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. * 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. * Use setup-python and native Windows toolchains in CI
203 lines
7.2 KiB
Python
203 lines
7.2 KiB
Python
"""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()
|