mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-22 04:23:06 +08:00
[feat] add Windows pre-login remote control and fix autostart
This commit is contained in:
@@ -58,6 +58,10 @@ function main(plat, portable)
|
||||
end
|
||||
|
||||
if plat == "windows" then
|
||||
for _, name in ipairs({"autostart", "windows_autostart_test"}) do
|
||||
assert(contains(effective_defines(targets[name]), "CROSSDESK_PORTABLE=1") ==
|
||||
(portable == "true"), name .. " has inconsistent autostart privilege policy")
|
||||
end
|
||||
local resources = {
|
||||
crossdesk = portable == "true" and "crossdesk_portable" or "crossdesk",
|
||||
crossdesk_service = "crossdesk_service",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Validate generated Task Scheduler XML on macOS/Linux without changing startup.
|
||||
|
||||
Run: python3 apps/desktop/tests/windows_autostart_task_test.py
|
||||
Native registration/migration coverage lives in windows_autostart_test.cpp.
|
||||
"""
|
||||
import pathlib
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[3]
|
||||
NAMESPACE = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
|
||||
|
||||
|
||||
class AutostartTaskTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.temporary = tempfile.TemporaryDirectory(prefix="crossdesk-autostart-test-")
|
||||
cls.addClassCleanup(cls.temporary.cleanup)
|
||||
directory = pathlib.Path(cls.temporary.name)
|
||||
source = directory / "fixture.cpp"
|
||||
source.write_text(r'''
|
||||
#include <iostream>
|
||||
#include "platform/windows/autostart_task.h"
|
||||
int main() {
|
||||
std::cout << crossdesk::platform::kAutostartRequiresElevation << '\n';
|
||||
const auto xml = crossdesk::platform::BuildAutostartTaskXml(
|
||||
L"S-1-5-21-123-456-789-1001", L"C:\\程序 & Tools\\CrossDesk.exe",
|
||||
L"C:\\程序 & Tools");
|
||||
for (wchar_t character : xml) std::cout << static_cast<unsigned>(character) << ' ';
|
||||
}
|
||||
''', encoding="utf-8")
|
||||
compiler = shutil.which("c++") or shutil.which("clang++")
|
||||
if not compiler:
|
||||
raise unittest.SkipTest("C++17 compiler is unavailable")
|
||||
cls.variants = {}
|
||||
for name, defines in {
|
||||
"installed": [], "portable": ["-DCROSSDESK_PORTABLE=1"],
|
||||
"debug": ["-DCROSSDESK_DEBUG"],
|
||||
"portable_debug": ["-DCROSSDESK_PORTABLE=1", "-DCROSSDESK_DEBUG"],
|
||||
}.items():
|
||||
binary = directory / name
|
||||
subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
|
||||
"-I", str(ROOT / "apps/desktop/src"), *defines,
|
||||
str(source), "-o", str(binary)], check=True)
|
||||
policy, codepoints = subprocess.check_output([str(binary)], text=True).split("\n", 1)
|
||||
xml = "".join(chr(int(value)) for value in codepoints.split())
|
||||
cls.variants[name] = (policy, ET.fromstring(xml))
|
||||
|
||||
def text(self, path):
|
||||
return self.variants["installed"][1].findtext(path, namespaces=NAMESPACE)
|
||||
|
||||
def test_privilege_policy_matches_build_type(self):
|
||||
self.assertEqual({name: result[0] for name, result in self.variants.items()},
|
||||
{"installed": "1", "portable": "0", "debug": "0", "portable_debug": "0"})
|
||||
|
||||
def test_login_targets_same_interactive_user_without_password(self):
|
||||
sid = "S-1-5-21-123-456-789-1001"
|
||||
self.assertEqual(self.text("t:Triggers/t:LogonTrigger/t:UserId"), sid)
|
||||
self.assertEqual(self.text("t:Principals/t:Principal/t:UserId"), sid)
|
||||
self.assertEqual(self.text("t:Principals/t:Principal/t:LogonType"), "InteractiveToken")
|
||||
self.assertEqual(self.text("t:Principals/t:Principal/t:RunLevel"), "HighestAvailable")
|
||||
self.assertEqual(self.text("t:Triggers/t:LogonTrigger/t:Enabled"), "true")
|
||||
|
||||
def test_command_is_not_shell_interpreted_and_unicode_round_trips(self):
|
||||
self.assertEqual(self.text("t:Actions/t:Exec/t:Command"), r"C:\程序 & Tools\CrossDesk.exe")
|
||||
self.assertEqual(self.text("t:Actions/t:Exec/t:WorkingDirectory"), r"C:\程序 & Tools")
|
||||
self.assertIsNone(self.text("t:Actions/t:Exec/t:Arguments"))
|
||||
task = self.variants["installed"][1]
|
||||
self.assertEqual(task.find("t:Actions", NAMESPACE).get("Context"),
|
||||
task.find("t:Principals/t:Principal", NAMESPACE).get("id"))
|
||||
|
||||
def test_remote_client_can_run_indefinitely_on_battery(self):
|
||||
self.assertEqual(self.text("t:Triggers/t:LogonTrigger/t:ExecutionTimeLimit"), "PT0S")
|
||||
self.assertEqual(self.text("t:Settings/t:DisallowStartIfOnBatteries"), "false")
|
||||
self.assertEqual(self.text("t:Settings/t:StopIfGoingOnBatteries"), "false")
|
||||
self.assertEqual(self.text("t:Settings/t:ExecutionTimeLimit"), "PT0S")
|
||||
self.assertEqual(self.text("t:Settings/t:MultipleInstancesPolicy"), "IgnoreNew")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,278 @@
|
||||
// Run from an elevated Windows terminal (the Windows CI runner is elevated).
|
||||
// Uses a unique registration name and removes it before returning.
|
||||
#include <windows.h>
|
||||
#include <sddl.h>
|
||||
#include <taskschd.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "platform/autostart_backend.h"
|
||||
#include "platform/windows/autostart_task.h"
|
||||
#include "rd_log.h"
|
||||
|
||||
namespace {
|
||||
using Microsoft::WRL::ComPtr;
|
||||
namespace platform = crossdesk::platform;
|
||||
constexpr wchar_t kRunKey[] =
|
||||
L"Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||
|
||||
bool Expect(bool condition, const char* message) {
|
||||
if (!condition) std::cerr << "FAIL: " << message << '\n';
|
||||
return condition;
|
||||
}
|
||||
|
||||
std::wstring UserSid() {
|
||||
HANDLE token = nullptr;
|
||||
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) return {};
|
||||
DWORD size = 0;
|
||||
GetTokenInformation(token, TokenUser, nullptr, 0, &size);
|
||||
std::vector<BYTE> data(size);
|
||||
const BOOL read =
|
||||
GetTokenInformation(token, TokenUser, data.data(), size, &size);
|
||||
CloseHandle(token);
|
||||
if (!read) return {};
|
||||
LPWSTR sid = nullptr;
|
||||
if (!ConvertSidToStringSidW(
|
||||
reinterpret_cast<TOKEN_USER*>(data.data())->User.Sid, &sid))
|
||||
return {};
|
||||
const std::wstring result(sid);
|
||||
LocalFree(sid);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool SetRun(const std::wstring& name, const std::wstring& path) {
|
||||
HKEY key = nullptr;
|
||||
if (RegCreateKeyExW(HKEY_CURRENT_USER, kRunKey, 0, nullptr, 0, KEY_SET_VALUE,
|
||||
nullptr, &key, nullptr) != ERROR_SUCCESS)
|
||||
return false;
|
||||
const std::wstring command = L"\"" + path + L"\"";
|
||||
const LONG result = RegSetValueExW(
|
||||
key, name.c_str(), 0, REG_SZ,
|
||||
reinterpret_cast<const BYTE*>(command.c_str()),
|
||||
static_cast<DWORD>((command.size() + 1) * sizeof(wchar_t)));
|
||||
RegCloseKey(key);
|
||||
return result == ERROR_SUCCESS;
|
||||
}
|
||||
|
||||
std::wstring ReadRun(const std::wstring& name) {
|
||||
wchar_t value[32768] = {};
|
||||
DWORD size = sizeof(value);
|
||||
if (RegGetValueW(HKEY_CURRENT_USER, kRunKey, name.c_str(), RRF_RT_REG_SZ,
|
||||
nullptr, value, &size) != ERROR_SUCCESS)
|
||||
return {};
|
||||
return value;
|
||||
}
|
||||
|
||||
bool CheckTask(ITaskFolder* folder, BSTR task_name,
|
||||
const std::filesystem::path& executable,
|
||||
const std::wstring& sid) {
|
||||
ComPtr<IRegisteredTask> task;
|
||||
if (!Expect(SUCCEEDED(folder->GetTask(task_name, task.GetAddressOf())),
|
||||
"task exists"))
|
||||
return false;
|
||||
ComPtr<ITaskDefinition> definition;
|
||||
if (FAILED(task->get_Definition(definition.GetAddressOf()))) return false;
|
||||
ComPtr<IPrincipal> principal;
|
||||
if (FAILED(definition->get_Principal(principal.GetAddressOf()))) return false;
|
||||
TASK_RUNLEVEL level = TASK_RUNLEVEL_LUA;
|
||||
TASK_LOGON_TYPE logon = TASK_LOGON_NONE;
|
||||
bool ok = Expect(SUCCEEDED(principal->get_RunLevel(&level)) &&
|
||||
level == TASK_RUNLEVEL_HIGHEST,
|
||||
"task requests elevation");
|
||||
ok &= Expect(SUCCEEDED(principal->get_LogonType(&logon)) &&
|
||||
logon == TASK_LOGON_INTERACTIVE_TOKEN,
|
||||
"task uses interactive user");
|
||||
BSTR user = nullptr;
|
||||
ok &= Expect(SUCCEEDED(principal->get_UserId(&user)) && user && sid == user,
|
||||
"task belongs to current user SID");
|
||||
SysFreeString(user);
|
||||
|
||||
ComPtr<ITriggerCollection> triggers;
|
||||
ComPtr<ITrigger> trigger;
|
||||
ComPtr<ILogonTrigger> logon_trigger;
|
||||
if (FAILED(definition->get_Triggers(triggers.GetAddressOf())) ||
|
||||
FAILED(triggers->get_Item(1, trigger.GetAddressOf())) ||
|
||||
FAILED(trigger.As(&logon_trigger)))
|
||||
return false;
|
||||
user = nullptr;
|
||||
ok &=
|
||||
Expect(SUCCEEDED(logon_trigger->get_UserId(&user)) && user && sid == user,
|
||||
"logon trigger is restricted to the same user");
|
||||
SysFreeString(user);
|
||||
|
||||
ComPtr<ITaskSettings> settings;
|
||||
if (FAILED(definition->get_Settings(settings.GetAddressOf()))) return false;
|
||||
VARIANT_BOOL value = VARIANT_TRUE;
|
||||
ok &= Expect(SUCCEEDED(settings->get_DisallowStartIfOnBatteries(&value)) &&
|
||||
value == VARIANT_FALSE,
|
||||
"starts on battery");
|
||||
ok &= Expect(SUCCEEDED(settings->get_StopIfGoingOnBatteries(&value)) &&
|
||||
value == VARIANT_FALSE,
|
||||
"survives switching to battery");
|
||||
BSTR limit = nullptr;
|
||||
ok &= Expect(SUCCEEDED(settings->get_ExecutionTimeLimit(&limit)) && limit &&
|
||||
std::wstring(limit) == L"PT0S",
|
||||
"has no three-day time limit");
|
||||
SysFreeString(limit);
|
||||
|
||||
ComPtr<IActionCollection> actions;
|
||||
ComPtr<IAction> action;
|
||||
ComPtr<IExecAction> exec;
|
||||
if (FAILED(definition->get_Actions(actions.GetAddressOf())) ||
|
||||
FAILED(actions->get_Item(1, action.GetAddressOf())) ||
|
||||
FAILED(action.As(&exec)))
|
||||
return false;
|
||||
BSTR path = nullptr;
|
||||
ok &= Expect(
|
||||
SUCCEEDED(exec->get_Path(&path)) && path && executable.wstring() == path,
|
||||
"Unicode command round-trips");
|
||||
SysFreeString(path);
|
||||
path = nullptr;
|
||||
ok &= Expect(SUCCEEDED(exec->get_WorkingDirectory(&path)) && path &&
|
||||
executable.parent_path().wstring() == path,
|
||||
"working directory is executable directory");
|
||||
SysFreeString(path);
|
||||
return ok;
|
||||
}
|
||||
|
||||
int RunTests(ITaskFolder* folder) {
|
||||
const std::string app =
|
||||
"CrossDeskAutostartTest-" + std::to_string(GetCurrentProcessId());
|
||||
const std::wstring name(app.begin(), app.end());
|
||||
const auto sid = UserSid();
|
||||
if (sid.empty()) return 1;
|
||||
const auto task_name = platform::AutostartTaskName(name, sid);
|
||||
BSTR task_bstr = SysAllocString(task_name.c_str());
|
||||
if (!task_bstr) return 1;
|
||||
const auto executable =
|
||||
std::filesystem::u8path(platform::GetAutostartExecutablePath());
|
||||
const auto temporary = std::filesystem::temp_directory_path() /
|
||||
(name + L" \u81ea\u542f & spaces");
|
||||
std::filesystem::create_directories(temporary);
|
||||
const auto alternate = temporary / L"CrossDesk \u6d4b\u8bd5.exe";
|
||||
// Registration never executes this file. Exercise XML/path handling without
|
||||
// leaving another runnable test program in the login path.
|
||||
std::ofstream(alternate) << "autostart registration test";
|
||||
struct Cleanup {
|
||||
ITaskFolder* folder;
|
||||
BSTR task;
|
||||
std::wstring name;
|
||||
std::filesystem::path directory;
|
||||
~Cleanup() {
|
||||
folder->DeleteTask(task, 0);
|
||||
SysFreeString(task);
|
||||
RegDeleteKeyValueW(HKEY_CURRENT_USER, kRunKey, name.c_str());
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(directory, error);
|
||||
}
|
||||
} cleanup{folder, task_bstr, name, temporary};
|
||||
|
||||
bool ok = Expect(platform::DisableAutostart(app),
|
||||
"disable absent entry is idempotent");
|
||||
ok &= Expect(!platform::IsAutostartEnabled(app), "initially disabled");
|
||||
ok &= Expect(platform::MigrateLegacyAutostart(app),
|
||||
"migration without a legacy entry is a no-op");
|
||||
ok &= Expect(!platform::IsAutostartEnabled(app),
|
||||
"migration never opts user in");
|
||||
ok &=
|
||||
Expect(SetRun(name, alternate.wstring()), "seed another copy's startup");
|
||||
ok &= Expect(platform::MigrateLegacyAutostart(app),
|
||||
"ignore another copy during migration");
|
||||
ok &= Expect(ReadRun(name) == L"\"" + alternate.wstring() + L"\"",
|
||||
"another copy's registration is unchanged");
|
||||
ok &= Expect(SetRun(name, executable.wstring()), "seed legacy Run entry");
|
||||
ok &= Expect(
|
||||
!platform::EnableAutostart(app, (temporary / L"missing.exe").u8string()),
|
||||
"reject missing executable");
|
||||
ok &= Expect(!ReadRun(name).empty(),
|
||||
"failed registration preserves legacy entry");
|
||||
ok &= Expect(platform::MigrateLegacyAutostart(app), "migrate legacy entry");
|
||||
ok &= Expect(platform::IsAutostartEnabled(app), "enabled after migration");
|
||||
|
||||
if (platform::kAutostartRequiresElevation) {
|
||||
ok &= Expect(ReadRun(name).empty(),
|
||||
"remove legacy entry after successful migration");
|
||||
ok &= CheckTask(folder, task_bstr, executable, sid);
|
||||
} else {
|
||||
ComPtr<IRegisteredTask> absent;
|
||||
ok &= Expect(FAILED(folder->GetTask(task_bstr, absent.GetAddressOf())),
|
||||
"portable/debug does not create elevated task");
|
||||
ok &= Expect(!ReadRun(name).empty(),
|
||||
"portable/debug retains ordinary startup");
|
||||
}
|
||||
ok &= Expect(platform::EnableAutostart(app, alternate.u8string()),
|
||||
"update executable path");
|
||||
ok &= Expect(platform::EnableAutostart(app, alternate.u8string()),
|
||||
"enable is idempotent");
|
||||
if (platform::kAutostartRequiresElevation) {
|
||||
ok &= CheckTask(folder, task_bstr, alternate, sid);
|
||||
ComPtr<IRegisteredTask> task;
|
||||
if (SUCCEEDED(folder->GetTask(task_bstr, task.GetAddressOf()))) {
|
||||
ok &= Expect(SUCCEEDED(task->put_Enabled(VARIANT_FALSE)),
|
||||
"disable task externally");
|
||||
ok &= Expect(!platform::IsAutostartEnabled(app),
|
||||
"disabled task is not reported enabled");
|
||||
ok &= Expect(platform::EnableAutostart(app, alternate.u8string()),
|
||||
"reenable task");
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
} else {
|
||||
ok &= Expect(ReadRun(name) == L"\"" + alternate.wstring() + L"\"",
|
||||
"Run command quotes Unicode path with spaces");
|
||||
}
|
||||
ok &= Expect(platform::RemoveInstalledAutostart(app),
|
||||
"uninstall skips another executable");
|
||||
ok &= Expect(platform::IsAutostartEnabled(app),
|
||||
"other executable keeps its startup");
|
||||
ok &= Expect(platform::EnableAutostart(app, executable.u8string()),
|
||||
"restore own executable");
|
||||
ok &= Expect(platform::RemoveInstalledAutostart(app),
|
||||
"uninstall removes own registration");
|
||||
ok &= Expect(!platform::IsAutostartEnabled(app),
|
||||
"uninstalled executable is no longer scheduled");
|
||||
ok &= Expect(platform::EnableAutostart(app, executable.u8string()),
|
||||
"reenable before disable test");
|
||||
// Disabling must clean up a leftover legacy entry as well as the task.
|
||||
ok &=
|
||||
Expect(SetRun(name, executable.wstring()), "seed leftover legacy entry");
|
||||
ok &=
|
||||
Expect(platform::DisableAutostart(app), "disable existing registration");
|
||||
ok &= Expect(ReadRun(name).empty() && !platform::IsAutostartEnabled(app),
|
||||
"all active registrations removed");
|
||||
ok &= Expect(platform::DisableAutostart(app), "disable twice succeeds");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
crossdesk::InitLogger(std::filesystem::temp_directory_path().string(),
|
||||
"crossdesk-autostart-test");
|
||||
// Exercise the existing-STA case used by GUI settings as well as normal COM
|
||||
// cleanup: the backend must not uninitialize this caller-owned apartment.
|
||||
if (FAILED(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED))) return 1;
|
||||
int result = 1;
|
||||
{
|
||||
ComPtr<ITaskService> service;
|
||||
ComPtr<ITaskFolder> folder;
|
||||
const VARIANT empty{};
|
||||
BSTR root = SysAllocString(L"\\");
|
||||
if (root &&
|
||||
SUCCEEDED(CoCreateInstance(CLSID_TaskScheduler, nullptr,
|
||||
CLSCTX_INPROC_SERVER,
|
||||
IID_PPV_ARGS(service.GetAddressOf()))) &&
|
||||
SUCCEEDED(service->Connect(empty, empty, empty, empty)) &&
|
||||
SUCCEEDED(service->GetFolder(root, folder.GetAddressOf()))) {
|
||||
result = RunTests(folder.Get());
|
||||
}
|
||||
SysFreeString(root);
|
||||
}
|
||||
CoUninitialize();
|
||||
if (result == 0) std::cout << "Windows autostart integration tests passed\n";
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
#include <Windows.h>
|
||||
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "service_lifetime.h"
|
||||
|
||||
int wmain(int argc, wchar_t** argv) {
|
||||
if (argc == 4 && std::wstring(argv[1]) == L"--child") {
|
||||
crossdesk::ServiceLifetimeWatcher watcher;
|
||||
if (!watcher.Start(argv[2])) return 2;
|
||||
HANDLE ready = OpenEventW(EVENT_MODIFY_STATE, FALSE, argv[3]);
|
||||
if (!ready) return 3;
|
||||
SetEvent(ready);
|
||||
CloseHandle(ready);
|
||||
Sleep(INFINITE); // The real watcher must terminate this child.
|
||||
return 4;
|
||||
}
|
||||
|
||||
wchar_t executable[32768]{};
|
||||
if (!GetModuleFileNameW(nullptr, executable, 32768)) return 1;
|
||||
for (bool abandon : {false, true}) {
|
||||
const auto suffix = std::to_wstring(GetCurrentProcessId()) +
|
||||
(abandon ? L"-abandon" : L"-release");
|
||||
const auto name = L"Global\\CrossDeskLifetimeTest-" + suffix;
|
||||
const auto ready_name = name + L"-ready";
|
||||
HANDLE owned = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
HANDLE exit_owner = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
HANDLE ready = CreateEventW(nullptr, TRUE, FALSE, ready_name.c_str());
|
||||
if (!owned || !exit_owner || !ready) return 1;
|
||||
HANDLE lifetime = nullptr;
|
||||
std::thread owner([&] {
|
||||
lifetime = CreateMutexW(nullptr, TRUE, name.c_str());
|
||||
SetEvent(owned);
|
||||
WaitForSingleObject(exit_owner, INFINITE);
|
||||
if (!abandon && lifetime) ReleaseMutex(lifetime);
|
||||
});
|
||||
WaitForSingleObject(owned, INFINITE);
|
||||
bool ok = lifetime != nullptr;
|
||||
if (ok) {
|
||||
// Cancellation during an ordinary helper exit must not wait for or
|
||||
// release the service's ownership.
|
||||
crossdesk::ServiceLifetimeWatcher cancellation;
|
||||
ok = cancellation.Start(name);
|
||||
}
|
||||
std::wstring command = L"\"" + std::wstring(executable) + L"\" --child \"" +
|
||||
name + L"\" \"" + ready_name + L"\"";
|
||||
STARTUPINFOW startup{};
|
||||
startup.cb = sizeof(startup);
|
||||
PROCESS_INFORMATION child{};
|
||||
if (ok)
|
||||
ok = CreateProcessW(executable, command.data(), nullptr, nullptr, FALSE,
|
||||
CREATE_NO_WINDOW, nullptr, nullptr, &startup,
|
||||
&child) != FALSE;
|
||||
if (ok)
|
||||
ok = WaitForSingleObject(ready, 10000) == WAIT_OBJECT_0 &&
|
||||
WaitForSingleObject(child.hProcess, 0) == WAIT_TIMEOUT;
|
||||
SetEvent(exit_owner);
|
||||
owner.join();
|
||||
if (ok) ok = WaitForSingleObject(child.hProcess, 5000) == WAIT_OBJECT_0;
|
||||
DWORD exit_code = 0;
|
||||
if (ok)
|
||||
ok = GetExitCodeProcess(child.hProcess, &exit_code) &&
|
||||
exit_code == ERROR_BROKEN_PIPE;
|
||||
if (child.hProcess) {
|
||||
if (!ok) TerminateProcess(child.hProcess, 1);
|
||||
WaitForSingleObject(child.hProcess, 5000);
|
||||
CloseHandle(child.hProcess);
|
||||
CloseHandle(child.hThread);
|
||||
}
|
||||
if (lifetime) CloseHandle(lifetime);
|
||||
CloseHandle(owned);
|
||||
CloseHandle(exit_owner);
|
||||
CloseHandle(ready);
|
||||
if (!ok) {
|
||||
std::cerr << "Service lifetime test failed, abandoned=" << abandon
|
||||
<< '\n';
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
std::cout
|
||||
<< "Service release, crash and watcher cancellation checks passed\n";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
|
||||
#include "platform/windows/service/unattended_policy.h"
|
||||
|
||||
int main() {
|
||||
using crossdesk::UnattendedAction;
|
||||
using Action = crossdesk::UnattendedProcessAction;
|
||||
const auto expect = [](bool value, const char* message) {
|
||||
if (!value) {
|
||||
std::cerr << message << '\n';
|
||||
std::exit(1);
|
||||
}
|
||||
};
|
||||
constexpr uint32_t none = 0xFFFFFFFFu;
|
||||
expect(UnattendedAction(false, 1, false, none) == Action::keep,
|
||||
"fresh installs must not start a network host");
|
||||
expect(UnattendedAction(true, none, false, none) == Action::keep,
|
||||
"boot before a console exists must wait");
|
||||
expect(UnattendedAction(true, 0, false, none) == Action::keep,
|
||||
"never capture service session zero");
|
||||
expect(UnattendedAction(true, 1, false, none) == Action::start,
|
||||
"a pre-login console must start the host");
|
||||
expect(UnattendedAction(true, 1, true, 1) == Action::keep,
|
||||
"login, lock and GUI exit must retain the same host");
|
||||
expect(UnattendedAction(true, 2, true, 1) == Action::stop,
|
||||
"user switching must stop capture in the old console first");
|
||||
expect(UnattendedAction(true, 2, false, none) == Action::start,
|
||||
"a replacement console must start after the old host exits");
|
||||
expect(UnattendedAction(true, none, true, 2) == Action::stop,
|
||||
"a detached console must not keep sending frames");
|
||||
expect(UnattendedAction(false, 2, true, 2) == Action::stop,
|
||||
"disabling unattended mode must stop its host");
|
||||
expect(UnattendedAction(true, 2, false, 2) == Action::start,
|
||||
"a crashed host must restart even if its session is unchanged");
|
||||
std::cout << "Unattended process lifecycle checks passed\n";
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#include <Windows.h>
|
||||
#include <Aclapi.h>
|
||||
#include <sddl.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
|
||||
#include "unattended_config.h"
|
||||
|
||||
namespace {
|
||||
bool Check(bool condition, const char* message) {
|
||||
if (!condition) std::cerr << message << ", Win32=" << GetLastError() << '\n';
|
||||
return condition;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
using crossdesk::unattended_detail::PreparePrivateDirectory;
|
||||
if (!Check(crossdesk::IsAdministratorProcess(), "Run this test elevated"))
|
||||
return 1;
|
||||
const auto root =
|
||||
std::filesystem::temp_directory_path() /
|
||||
(L"crossdesk-unattended-test-" + std::to_wstring(GetCurrentProcessId()));
|
||||
struct Cleanup {
|
||||
std::filesystem::path path;
|
||||
~Cleanup() {
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(path, error);
|
||||
}
|
||||
} cleanup{root};
|
||||
// Never reuse/remove a directory we did not create for this test.
|
||||
if (std::filesystem::exists(root)) {
|
||||
cleanup.path.clear();
|
||||
return 1;
|
||||
}
|
||||
if (!Check(PreparePrivateDirectory(root, true), "create private profile") ||
|
||||
!Check(PreparePrivateDirectory(root, true),
|
||||
"idempotent profile validation"))
|
||||
return 1;
|
||||
const auto secret = root / L"credential.txt";
|
||||
{
|
||||
std::ofstream output(secret);
|
||||
output << "test credential";
|
||||
}
|
||||
|
||||
HANDLE token = nullptr, restricted = nullptr;
|
||||
BYTE admin_sid[SECURITY_MAX_SID_SIZE];
|
||||
DWORD sid_size = sizeof(admin_sid);
|
||||
if (!CreateWellKnownSid(WinBuiltinAdministratorsSid, nullptr, admin_sid,
|
||||
&sid_size) ||
|
||||
!OpenProcessToken(GetCurrentProcess(),
|
||||
TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY |
|
||||
TOKEN_IMPERSONATE,
|
||||
&token))
|
||||
return 1;
|
||||
SID_AND_ATTRIBUTES disabled{admin_sid, 0};
|
||||
const bool restricted_ok =
|
||||
CreateRestrictedToken(token, DISABLE_MAX_PRIVILEGE, 1, &disabled, 0,
|
||||
nullptr, 0, nullptr, &restricted) != FALSE;
|
||||
CloseHandle(token);
|
||||
if (!Check(restricted_ok, "create unprivileged token")) return 1;
|
||||
const bool impersonated = ImpersonateLoggedOnUser(restricted) != FALSE;
|
||||
bool denied = false;
|
||||
if (impersonated) {
|
||||
HANDLE file = CreateFileW(secret.c_str(), GENERIC_READ, FILE_SHARE_READ,
|
||||
nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
denied =
|
||||
file == INVALID_HANDLE_VALUE && GetLastError() == ERROR_ACCESS_DENIED;
|
||||
if (file != INVALID_HANDLE_VALUE) CloseHandle(file);
|
||||
if (!RevertToSelf()) std::terminate();
|
||||
}
|
||||
CloseHandle(restricted);
|
||||
if (!Check(impersonated && denied,
|
||||
"ordinary token must not read credentials"))
|
||||
return 1;
|
||||
|
||||
const auto link = root / L"link";
|
||||
if (!Check(CreateSymbolicLinkW(link.c_str(), root.c_str(),
|
||||
SYMBOLIC_LINK_FLAG_DIRECTORY) != FALSE,
|
||||
"create directory link") ||
|
||||
!Check(!PreparePrivateDirectory(link, false),
|
||||
"reject directory reparse points"))
|
||||
return 1;
|
||||
std::filesystem::remove(link);
|
||||
|
||||
PSECURITY_DESCRIPTOR descriptor = nullptr;
|
||||
if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
L"D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;GR;;;WD)",
|
||||
SDDL_REVISION_1, &descriptor, nullptr))
|
||||
return 1;
|
||||
BOOL present = FALSE, defaulted = FALSE;
|
||||
PACL dacl = nullptr;
|
||||
GetSecurityDescriptorDacl(descriptor, &present, &dacl, &defaulted);
|
||||
const auto result = SetNamedSecurityInfoW(
|
||||
const_cast<wchar_t*>(root.c_str()), SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, nullptr,
|
||||
nullptr, dacl, nullptr);
|
||||
LocalFree(descriptor);
|
||||
if (!Check(result == ERROR_SUCCESS, "make profile permissive") ||
|
||||
!Check(!PreparePrivateDirectory(root, false),
|
||||
"reject exposed credentials") ||
|
||||
!Check(!PreparePrivateDirectory(root, true),
|
||||
"never silently adopt an unsafe profile"))
|
||||
return 1;
|
||||
std::cout << "Unattended profile ACL and reparse-point checks passed\n";
|
||||
}
|
||||
Reference in New Issue
Block a user