Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,12 @@ jobs:
run: ./build.ps1 -Arch $env:PATHMAN_ARCH -VersionName '0.0.0' -VersionNumber '0.0.0' -ReleaseType official
- name: Build, run and package
run: ./build.ps1 -Arch $env:PATHMAN_ARCH -VersionName $env:PATHMAN_VERSION -VersionNumber $env:PATHMAN_VERSION_NUMBER -ReleaseType $env:PATHMAN_RELEASE_TYPE -Test
- name: Test origin tracking authorization
if: matrix.arch == 'x64' && !env.ACT
run: ./tests/origin-task.ps1
- name: Test unified monitoring service
if: matrix.arch == 'x64' && !env.ACT
run: ./tests/global-monitor.ps1
- name: Accept final installers
if: ${{ !env.ACT }}
run: ./tests/installer-acceptance.ps1 -Architecture $env:PATHMAN_ARCH -Version $env:PATHMAN_VERSION
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ PATH 不可作为版本目标。内置系统变量默认不可选,可在“设

用户和系统范围分别提供外部变量归类、PATH 去重与迁移选项,也可排除系统环境编辑器或 PowerToys 的修改。用户外部路径迁入 `EXTERNAL_USER_PATH`,系统外部路径迁入 `EXTERNAL_PATH`。

“精确识别修改来源”需要管理员权限,用于记录写入进程并应用排除规则;来源无法确认时保留原值。系统 PATH 自动整理也需要管理员授权。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。
“精确识别修改来源”首次启用时需要管理员授权,之后由计划任务启动只读的来源采集程序,登录时不会重复弹出 UAC。管理员本人的采集任务使用其提权令牌,跨用户授权的采集任务使用 SYSTEM。监控主进程仍以当前用户权限运行。来源无法确认时保留原值,个人模式下的系统 PATH 自动整理仍需单独授权。通过“外部更改记录”查看更改前后内容、时间、作用范围和写入进程。

为所有用户安装时,“系统变量”下面还提供“全局设置”。开启“为所有用户开启统一设置”后,由一个自动启动的 Windows 服务承担监控与来源采集,个人后台程序退出;个人的系统 PATH 整理选项停用,其保存的值保留。全局设置可分别启用非管理员用户的精确来源识别和系统 PATH 自动整理,配置、机器级记录及备份保存在受保护的 `%ProgramData%\PathMan\monitor`。服务按用户令牌处理各自的用户变量与个人配置。关闭统一设置后,恢复符合个人后台运行和登录自启条件的后台程序。仅为自己安装时不显示全局设置。

### 设置与关于

Expand Down
13 changes: 13 additions & 0 deletions packaging/PathMan.nsi
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,12 @@ Section "PathMan" Main
SetErrorLevel 1603
Abort
${EndIf}
System::Call '$Helper::MaintainOriginTask(w "$INSTDIR", w "$UserLocalData", w "$OriginalUserSid", i $Machine) i.r0'
${If} $0 != 0
MessageBox MB_OK|MB_ICONSTOP "$(MaintenanceFailed)" /SD IDOK
SetErrorLevel 1603
Abort
${EndIf}
InstallUserFinalization:
${If} $Worker == 1
Call RestoreBackground
Expand Down Expand Up @@ -752,6 +758,12 @@ Section "Uninstall"
SetErrorLevel 0
Return
${EndIf}
System::Call '$Helper::RemoveOriginTasks(w "$INSTDIR", i $Machine) i.r0'
${If} $0 != 0
MessageBox MB_OK|MB_ICONSTOP "$(MaintenanceFailed)" /SD IDOK
SetErrorLevel 1603
Abort
${EndIf}
System::Call '$Helper::CleanupInstall(w "$INSTDIR", i $Machine, w "$DESKTOP") i.r0'
${If} $0 != 0
MessageBox MB_OK|MB_ICONSTOP "$(MaintenanceFailed)" /SD IDOK
Expand All @@ -766,6 +778,7 @@ Section "Uninstall"
!insertmacro DeleteInstalledFile "PathMan.exe"
!insertmacro DeleteInstalledFile "LICENSE"
!insertmacro DeleteInstalledFile "release.json"
!insertmacro DeleteInstalledFile "internal\service-payload.json"
!insertmacro DeleteInstalledFile "THIRD-PARTY-NOTICES.txt"
!insertmacro DeleteInstalledFile "licenses\dotnet-THIRD-PARTY-NOTICES.txt"
!insertmacro DeleteInstalledFile "bin\pathman.exe"
Expand Down
1 change: 1 addition & 0 deletions packaging/native/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ if(BUILD_TESTING)
endif()
add_test(NAME setup-path COMMAND PathMan.SetupPathTests)
add_executable(PathMan.SetupWorkerTests ../../tests/setup-worker.cpp)
target_link_libraries(PathMan.SetupWorkerTests PRIVATE advapi32)
target_compile_features(PathMan.SetupWorkerTests PRIVATE cxx_std_20)
target_compile_definitions(PathMan.SetupWorkerTests PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX)
set_target_properties(PathMan.SetupWorkerTests PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
Expand Down
41 changes: 41 additions & 0 deletions packaging/native/actions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
#include <cwctype>
#include "version.h"
#include "verified-worker.h"
#include "protected-source.h"
#include <memory>

#ifdef _M_IX86
#pragma comment(linker, "/EXPORT:CompareVersions=_CompareVersions@8")
Expand All @@ -31,6 +33,8 @@
#pragma comment(linker, "/EXPORT:BeginSetup=_BeginSetup@0")
#pragma comment(linker, "/EXPORT:CleanupUserInstall=_CleanupUserInstall@8")
#pragma comment(linker, "/EXPORT:PrepareOriginBroker=_PrepareOriginBroker@12")
#pragma comment(linker, "/EXPORT:RemoveOriginTasks=_RemoveOriginTasks@8")
#pragma comment(linker, "/EXPORT:MaintainOriginTask=_MaintainOriginTask@16")
#endif

extern "C" __declspec(dllexport) DWORD __stdcall CompareVersions(const wchar_t* left,const wchar_t* right) {
Expand Down Expand Up @@ -470,6 +474,43 @@ DWORD run_verified_elevated(HWND owner, const std::filesystem::path& worker, con
return result;
}
}
namespace {
DWORD origin_maintenance(const wchar_t* directory, const std::wstring& parameters, BOOL machine) {
try {
const auto executable = std::filesystem::path(directory) / L"internal" / L"PathMan.Service.exe";
if (!std::filesystem::exists(executable)) return ERROR_SUCCESS;
std::unique_ptr<pathman_setup::protected_source> source;
if (machine) {
source = std::make_unique<pathman_setup::protected_source>(std::filesystem::path(directory));
if (source->error()) return source->error();
}
auto command = L"\"" + executable.wstring() + L"\" " + parameters;
STARTUPINFOW start{sizeof(start)}; PROCESS_INFORMATION process{};
if (!CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, directory, &start, &process)) return GetLastError();
wait_with_messages(process.hProcess);
DWORD result = ERROR_INSTALL_FAILURE;
GetExitCodeProcess(process.hProcess, &result);
CloseHandle(process.hThread); CloseHandle(process.hProcess);
if (result != ERROR_ELEVATION_REQUIRED) return result;
SHELLEXECUTEINFOW execute{sizeof(execute)};
execute.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI;
execute.lpVerb = L"runas"; execute.lpFile = executable.c_str(); execute.lpParameters = parameters.c_str(); execute.nShow = SW_HIDE;
if (!ShellExecuteExW(&execute)) return GetLastError();
if (!execute.hProcess) return ERROR_INSTALL_FAILURE;
wait_with_messages(execute.hProcess);
GetExitCodeProcess(execute.hProcess, &result); CloseHandle(execute.hProcess);
return result;
} catch (...) { return ERROR_INSTALL_FAILURE; }
}
}
extern "C" __declspec(dllexport) DWORD __stdcall RemoveOriginTasks(const wchar_t* directory, BOOL machine) {
return origin_maintenance(directory, L"--remove-origin-tasks", machine);
}
extern "C" __declspec(dllexport) DWORD __stdcall MaintainOriginTask(const wchar_t* directory, const wchar_t* localData, const wchar_t* originalSid, BOOL machine) {
const auto sid = originalSid && *originalSid ? std::wstring(originalSid) : current_sid();
const auto data = std::filesystem::path(localData) / L"PathMan";
return origin_maintenance(directory, L"--maintain-origin-task \"" + sid + L"\" \"" + data.wstring() + L"\"", machine);
}
extern "C" __declspec(dllexport) DWORD __stdcall PrepareOriginBroker(const wchar_t* directory, const wchar_t* localData, const wchar_t* originalSid) {
try {
const auto sid = originalSid && *originalSid ? std::wstring(originalSid) : current_sid();
Expand Down
69 changes: 69 additions & 0 deletions packaging/native/protected-source.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#pragma once
#include <windows.h>
#include <aclapi.h>
#include <array>
#include <filesystem>
#include <vector>

namespace pathman_setup {
inline bool trusted_owner(PSID sid) {
if (!sid) return false;
if (IsWellKnownSid(sid, WinLocalSystemSid) || IsWellKnownSid(sid, WinBuiltinAdministratorsSid)) return true;
std::array<BYTE, SECURITY_MAX_SID_SIZE> installer{};
wchar_t domain[256]{}; DWORD bytes = static_cast<DWORD>(installer.size()), length = 256; SID_NAME_USE use{};
return LookupAccountNameW(nullptr, L"NT SERVICE\\TrustedInstaller", installer.data(), &bytes, domain, &length, &use) && EqualSid(sid, installer.data());
}
inline bool protected_descriptor(PSECURITY_DESCRIPTOR security, bool payload) {
PSID owner = nullptr; BOOL defaulted = FALSE, present = FALSE; PACL acl = nullptr;
if (!GetSecurityDescriptorOwner(security, &owner, &defaulted) || !trusted_owner(owner) ||
!GetSecurityDescriptorDacl(security, &present, &acl, &defaulted) || !present || !acl) return false;
const DWORD dangerous = 0x500d0152u | (payload ? 4u : 0u);
for (DWORD index = 0; index < acl->AceCount; ++index) {
void* entry = nullptr; if (!GetAce(acl, index, &entry)) return false;
const auto header = static_cast<ACE_HEADER*>(entry);
if (header->AceFlags & INHERIT_ONLY_ACE) continue;
if (header->AceType == ACCESS_DENIED_ACE_TYPE) continue;
if (header->AceType != ACCESS_ALLOWED_ACE_TYPE) return false;
const auto allow = static_cast<ACCESS_ALLOWED_ACE*>(entry);
if ((allow->Mask & dangerous) && !trusted_owner(&allow->SidStart)) return false;
}
return true;
}
class protected_source {
std::vector<HANDLE> handles_;
DWORD error_ = ERROR_SUCCESS;
bool open(const std::filesystem::path& path, bool directory, bool payload) {
const auto handle = CreateFileW(path.c_str(), directory ? READ_CONTROL | FILE_READ_ATTRIBUTES : GENERIC_READ | READ_CONTROL,
directory ? FILE_SHARE_READ | FILE_SHARE_WRITE : FILE_SHARE_READ, nullptr, OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT | (directory ? FILE_FLAG_BACKUP_SEMANTICS : 0), nullptr);
if (handle == INVALID_HANDLE_VALUE) { error_ = GetLastError(); return false; }
handles_.push_back(handle);
BY_HANDLE_FILE_INFORMATION info{};
if (!GetFileInformationByHandle(handle, &info)) { error_ = GetLastError(); return false; }
if (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) { error_ = ERROR_ACCESS_DENIED; return false; }
PSID owner = nullptr; PACL acl = nullptr; PSECURITY_DESCRIPTOR security = nullptr;
error_ = GetSecurityInfo(handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, nullptr, &acl, nullptr, &security);
if (error_) return false;
const bool valid = protected_descriptor(security, payload); LocalFree(security);
if (!valid) error_ = ERROR_ACCESS_DENIED;
return valid;
}
public:
explicit protected_source(const std::filesystem::path& installation) {
const auto root = installation.root_path();
if (!installation.is_absolute() || GetDriveTypeW(root.c_str()) != DRIVE_FIXED) { error_ = ERROR_ACCESS_DENIED; return; }
std::vector<std::filesystem::path> paths;
for (auto path = installation / L"internal";; path = path.parent_path()) {
paths.push_back(path); if (path == root) break;
if (path.empty() || path == path.parent_path()) { error_ = ERROR_BAD_PATHNAME; return; }
}
for (auto it = paths.rbegin(); it != paths.rend(); ++it)
if (!open(*it, true, *it == installation || *it == installation / L"internal")) return;
open(installation / L"internal" / L"PathMan.Service.exe", false, true);
}
~protected_source() { for (const auto handle : handles_) CloseHandle(handle); }
protected_source(const protected_source&) = delete;
protected_source& operator=(const protected_source&) = delete;
DWORD error() const { return error_; }
};
}
1 change: 1 addition & 0 deletions scripts/build-matrix.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ foreach ($cpu in $Arch) {
'-p:DebugType=None', '-o', $project.Destination)+$versionProperties)
}
Copy-Item -LiteralPath (Join-Path $nativeBuild "$Configuration/PathMan.Shim.exe") -Destination (Join-Path $output 'internal/PathMan.Shim.exe')
@{sha256=(Get-FileHash -LiteralPath (Join-Path $output 'internal/PathMan.Service.exe') -Algorithm SHA256).Hash} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $output 'internal/service-payload.json') -Encoding utf8
Copy-Item -LiteralPath (Join-Path $projectRoot 'LICENSE') -Destination $output
Copy-Item -LiteralPath (Join-Path $projectRoot 'THIRD-PARTY-NOTICES.txt') -Destination $output
Copy-Item -LiteralPath (Join-Path $projectRoot 'licenses') -Destination $output -Recurse
Expand Down
7 changes: 4 additions & 3 deletions scripts/test.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ try {
$layout=[Pathman.Core.PathmanLayout]::new($installation,$dataRoot)
$manager=[Pathman.Core.Shims.ShimManager]::new($layout,$false)
$background=[Pathman.Core.BackgroundProcess]::new($layout)
$unified=[Pathman.Core.GlobalMonitoring]::new($layout).IsEnabled
$name = "p'w'sh-中文"
$null = $manager.Add($name,$pwsh)
$alias = Join-Path $dataRoot "shims/$name.exe"
Expand Down Expand Up @@ -147,13 +148,13 @@ try {
Assert (!(Test-Path -LiteralPath (Join-Path $dataRoot "shims/$name.shim"))) 'Removing an alias left its manifest behind.'
$missing = Invoke-Program $template @()
Assert ($missing.Code -eq 13) 'A missing manifest should fail clearly.'
Assert-Rejected {$background.Start()}
if($unified){$background.Start()}else{Assert-Rejected {$background.Start()}}
Assert (!$background.IsRunning) 'Disabled background must remain stopped.'
$duplicate = Invoke-Program (Join-Path $installation 'internal/PathMan.Service.exe') @('--data-root', $dataRoot)
Assert ($duplicate.Code -eq 5) 'A direct disabled background launch must also be refused.'
Assert ($duplicate.Code -eq $(if($unified){0}else{5})) 'A direct launch must leave the personal background stopped when disabled or owned by the unified service.'
$background.Stop()
Assert (!$background.IsRunning) 'Background process did not stop.'
Assert-Rejected {$background.Start()}
if($unified){$background.Start()}else{Assert-Rejected {$background.Start()}}
$background.Stop()

if ([IO.Path]::GetPathRoot($testRoot) -ne [IO.Path]::GetPathRoot([IO.Path]::GetTempPath())) {
Expand Down
3 changes: 3 additions & 0 deletions src/Pathman.Core/BackgroundProcess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ namespace Pathman.Core;

public sealed class BackgroundProcess(PathmanLayout layout)
{
public static Mutex OwnerMutex(string sid,string installation,int? sessionId=null)=>WindowsUserContext.WorkspaceMutex(@"Global\PathMan.MonitorOwner."+sid+".Session."+(sessionId ?? Process.GetCurrentProcess().SessionId));
public static Mutex WriterMutex(string sid)=>WindowsUserContext.WorkspaceMutex(@"Global\PathMan.MonitorWriter."+sid);
// Per-user, per-installation names prevent another installation from stopping this process.
public string InstanceName
{
Expand All @@ -32,6 +34,7 @@ public bool IsRunning

public void Start()
{
if(new GlobalMonitoring(layout).IsEnabled) return;
if (IsRunning) return;
var state=new EnvironmentWorkspaceStore(layout.DataDirectory).Read();
if(!StartupManager.CanRun(state,new StartupManager(layout).IsEnabled))
Expand Down
Loading