From 4f74c2722bac1248180091ce3686b5280b19723b Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 11 Sep 2026 13:54:00 -0400 Subject: [PATCH 1/3] Elevate the removal of shortcuts the current user cannot delete --- docs/CLI.md | 1 + src/Shared/SharedPreUiCommandDispatcher.cs | 31 ++ .../DialogPages/ManageShortcutsViewModel.cs | 21 +- .../ShortcutFileRemoverTests.cs | 187 +++++++ .../ShortcutFileRemover.cs | 480 ++++++++++++++++++ .../Classes/DesktopShortcutsDatabase.cs | 28 +- .../Classes/StartMenuShortcutsDatabase.cs | 45 +- src/UniGetUI.Tests/CLIHandlerTests.cs | 65 +++ 8 files changed, 828 insertions(+), 30 deletions(-) create mode 100644 src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs create mode 100644 src/UniGetUI.Core.Tools/ShortcutFileRemover.cs diff --git a/docs/CLI.md b/docs/CLI.md index ab0204cac2..73a7629db4 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -258,6 +258,7 @@ These parameters are accepted by the app executables in addition to the automati | `--no-corrupt-dialog` | Shows the verbose crash report instead of the simplified dialog. | Troubleshooting flag. | | `--enable-secure-setting ` / `--disable-secure-setting ` | Toggles one secure setting for the current user. | May require elevation. | | `--enable-secure-setting-for-user ` / `--disable-secure-setting-for-user ` | Toggles one secure setting for a specified user. | May require elevation. | +| `--delete-shortcuts [...]` | Deletes desktop or Start Menu shortcuts, then quits. | Used by UniGetUI to remove shortcuts the current user is not allowed to delete; refuses any path that is not a `.lnk` or `.url` under a desktop or Start Menu folder. | | `` | Loads a valid bundle file into the Package Bundles page. | Supported extensions include `.ubundle`, `.json`, `.yaml`, and `.xml`. | ## Other environment variables diff --git a/src/Shared/SharedPreUiCommandDispatcher.cs b/src/Shared/SharedPreUiCommandDispatcher.cs index 945ca6b880..9a298119af 100644 --- a/src/Shared/SharedPreUiCommandDispatcher.cs +++ b/src/Shared/SharedPreUiCommandDispatcher.cs @@ -39,6 +39,7 @@ internal static class SharedPreUiCommandDispatcher internal const string SetSettingValueArgument = "--set-setting-value"; internal const string EnableSecureSettingArgument = "--enable-secure-setting"; internal const string DisableSecureSettingArgument = "--disable-secure-setting"; + internal const string DeleteShortcutsArgument = ShortcutFileRemover.CliArgument; internal const string MigrateWingetUIToUniGetUIArgument = "--migrate-wingetui-to-unigetui"; internal const string UninstallWingetUIArgument = "--uninstall-wingetui"; internal const string UninstallUniGetUIArgument = "--uninstall-unigetui"; @@ -120,6 +121,11 @@ public static string[] IgnoreArgumentsInjectedIntoProtocolLaunch(string[] args) return DisableSecureSettingForUser(args, exitCodes); } + if (args.Contains(DeleteShortcutsArgument)) + { + return DeleteShortcuts(args, exitCodes); + } + if (args.Contains(MigrateWingetUIToUniGetUIArgument)) { return MigrateWingetUIToUniGetUI(); @@ -326,6 +332,31 @@ public static int DisableSecureSettingForUser(IReadOnlyList args, Shared } } + public static int DeleteShortcuts( + IReadOnlyList args, + SharedPreUiCommandExitCodes exitCodes + ) + { + int basePos = FindArgumentIndex(args, DeleteShortcutsArgument); + if (basePos < 0 || basePos + 1 >= args.Count) + { + return exitCodes.InvalidParameter; + } + + string[] shortcutPaths = [.. args.Skip(basePos + 1)]; + + try + { + return ShortcutFileRemover.DeleteAsElevatedInstance(shortcutPaths) is 0 + ? exitCodes.Success + : exitCodes.Failed; + } + catch (Exception ex) + { + return ex.HResult; + } + } + public static int MigrateWingetUIToUniGetUI() { try diff --git a/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageShortcutsViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageShortcutsViewModel.cs index a1822b0527..670a39617e 100644 --- a/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageShortcutsViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/DialogPages/ManageShortcutsViewModel.cs @@ -274,6 +274,9 @@ private void ResetAll() public void SaveChanges() { + List desktopShortcutsToDelete = []; + List startMenuShortcutsToDelete = []; + if (ShowDesktopTab) CoreSettings.Set(CoreSettings.K.RemoveAllDesktopShortcuts, AutoDelete); @@ -293,7 +296,7 @@ public void SaveChanges() DesktopShortcutsDatabase.RemoveFromUnknownShortcuts(entry.Path); if (entry.IsDeletable && File.Exists(entry.Path)) - DesktopShortcutsDatabase.DeleteFromDisk(entry.Path); + desktopShortcutsToDelete.Add(entry.Path); } CaptureUnsavedStartMenuVerdicts(); @@ -311,7 +314,7 @@ public void SaveChanges() continue; if (File.Exists(path)) - StartMenuShortcutsDatabase.DeleteFromDisk(path); + startMenuShortcutsToDelete.Add(path); } _unsavedStartMenuVerdicts.Clear(); @@ -329,8 +332,20 @@ public void SaveChanges() ); if (File.Exists(shortcut)) - StartMenuShortcutsDatabase.DeleteFromDisk(shortcut); + startMenuShortcutsToDelete.Add(shortcut); } + } + + if (desktopShortcutsToDelete.Count > 0) + DesktopShortcutsDatabase.DeleteFromDisk(desktopShortcutsToDelete); + + if (startMenuShortcutsToDelete.Count > 0) + StartMenuShortcutsDatabase.DeleteFromDisk(startMenuShortcutsToDelete); + + foreach (var rule in StartMenuRules) + { + if (rule.FolderIsInvalid) + continue; StartMenuShortcutsDatabase.SetRule(rule.PackageId, rule.Folder); StartMenuShortcutsDatabase.RebaseRelocations(rule.PackageId); diff --git a/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs b/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs new file mode 100644 index 0000000000..9c598112dc --- /dev/null +++ b/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs @@ -0,0 +1,187 @@ +namespace UniGetUI.Core.Tools.Tests; + +public sealed class ShortcutFileRemoverTests : IDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), + nameof(ShortcutFileRemoverTests), + Guid.NewGuid().ToString("N") + ); + + private readonly string _outsideRoot = Path.Combine( + Path.GetTempPath(), + nameof(ShortcutFileRemoverTests), + Guid.NewGuid().ToString("N") + ); + + public ShortcutFileRemoverTests() + { + Directory.CreateDirectory(_root); + Directory.CreateDirectory(_outsideRoot); + ShortcutFileRemover.TEST_ShortcutRootsOverride = [_root]; + } + + public void Dispose() + { + ShortcutFileRemover.TEST_ShortcutRootsOverride = null; + foreach (string directory in new[] { _root, _outsideRoot }) + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + + private static string CreateFile(string directory, string name) + { + Directory.CreateDirectory(directory); + string path = Path.Combine(directory, name); + File.WriteAllText(path, "shortcut"); + return path; + } + + [Fact] + public void ShortcutsUnderAKnownRootAreRemovable() + { + Assert.True( + ShortcutFileRemover.IsRemovableShortcutPath(Path.Combine(_root, "LibreOffice 26.8.lnk")) + ); + Assert.True(ShortcutFileRemover.IsRemovableShortcutPath(Path.Combine(_root, "Site.URL"))); + Assert.True( + ShortcutFileRemover.IsRemovableShortcutPath( + Path.Combine(_root, "LibreOffice", "Writer.lnk") + ) + ); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void BlankPathsAreNotRemovable(string path) + { + Assert.False(ShortcutFileRemover.IsRemovableShortcutPath(path)); + } + + [Fact] + public void PathsOutsideEveryKnownRootAreNotRemovable() + { + Assert.False( + ShortcutFileRemover.IsRemovableShortcutPath(Path.Combine(_outsideRoot, "Payload.lnk")) + ); + Assert.False( + ShortcutFileRemover.IsRemovableShortcutPath( + Path.Combine(_root, "..", "Payload.lnk") + ) + ); + } + + [Fact] + public void OnlyShortcutFilesAreRemovable() + { + Assert.False( + ShortcutFileRemover.IsRemovableShortcutPath(Path.Combine(_root, "Payload.exe")) + ); + Assert.False( + ShortcutFileRemover.IsRemovableShortcutPath(Path.Combine(_root, "Payload")) + ); + } + + [Fact] + public void AlternateDataStreamsAreNotRemovable() + { + Assert.False( + ShortcutFileRemover.IsRemovableShortcutPath( + Path.Combine(_root, "Writer.lnk:stream.lnk") + ) + ); + Assert.False( + ShortcutFileRemover.IsRemovableShortcutPath(Path.Combine(_root, "Payload.exe:.lnk")) + ); + } + + [Fact] + public void WildcardsAreNotRemovable() + { + Assert.False(ShortcutFileRemover.IsRemovableShortcutPath(Path.Combine(_root, "*.lnk"))); + Assert.False(ShortcutFileRemover.IsRemovableShortcutPath(Path.Combine(_root, "?.lnk"))); + } + + [Fact] + public void DeleteRemovesAnOrdinaryShortcut() + { + string shortcut = CreateFile(_root, "Writer.lnk"); + + Assert.True(ShortcutFileRemover.Delete(shortcut)); + Assert.False(File.Exists(shortcut)); + } + + [Fact] + public void DeleteRemovesAReadOnlyShortcut() + { + string shortcut = CreateFile(_root, "ReadOnly.lnk"); + File.SetAttributes(shortcut, File.GetAttributes(shortcut) | FileAttributes.ReadOnly); + + Assert.True(ShortcutFileRemover.Delete(shortcut)); + Assert.False(File.Exists(shortcut)); + } + + [Fact] + public void DeleteReportsFailureForAShortcutThatIsInUse() + { + string shortcut = CreateFile(_root, "InUse.lnk"); + using var handle = new FileStream( + shortcut, + FileMode.Open, + FileAccess.Read, + FileShare.None + ); + + Assert.False(ShortcutFileRemover.Delete(shortcut)); + Assert.True(File.Exists(shortcut)); + } + + [Fact] + public void DeletingABatchRemovesEveryShortcut() + { + string[] shortcuts = + [ + CreateFile(_root, "One.lnk"), + CreateFile(_root, "Two.lnk"), + CreateFile(Path.Combine(_root, "Nested"), "Three.lnk"), + ]; + + ShortcutFileRemover.Delete(shortcuts); + + Assert.All(shortcuts, shortcut => Assert.False(File.Exists(shortcut))); + } + + [Fact] + public void TheElevatedInstanceRemovesShortcutsUnderAKnownRoot() + { + string shortcut = CreateFile(_root, "Writer.lnk"); + + Assert.Equal(0, ShortcutFileRemover.DeleteAsElevatedInstance([shortcut])); + Assert.False(File.Exists(shortcut)); + } + + [Fact] + public void TheElevatedInstanceRefusesPathsItDoesNotManage() + { + string outside = CreateFile(_outsideRoot, "Payload.lnk"); + string wrongExtension = CreateFile(_root, "Payload.exe"); + + Assert.Equal(1, ShortcutFileRemover.DeleteAsElevatedInstance([outside, wrongExtension])); + Assert.True(File.Exists(outside)); + Assert.True(File.Exists(wrongExtension)); + } + + [Fact] + public void TheElevatedInstanceReportsFailureWhenOnlySomePathsAreManaged() + { + string managed = CreateFile(_root, "Writer.lnk"); + string outside = CreateFile(_outsideRoot, "Payload.lnk"); + + Assert.Equal(1, ShortcutFileRemover.DeleteAsElevatedInstance([managed, outside])); + Assert.False(File.Exists(managed)); + Assert.True(File.Exists(outside)); + } +} diff --git a/src/UniGetUI.Core.Tools/ShortcutFileRemover.cs b/src/UniGetUI.Core.Tools/ShortcutFileRemover.cs new file mode 100644 index 0000000000..4bcfafe1b1 --- /dev/null +++ b/src/UniGetUI.Core.Tools/ShortcutFileRemover.cs @@ -0,0 +1,480 @@ +using System.ComponentModel; +using System.Diagnostics; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.Core.SettingsEngine; + +namespace UniGetUI.Core.Tools; + +public static class ShortcutFileRemover +{ + public const string CliArgument = "--delete-shortcuts"; + + private const int ErrorCancelled = 1223; + + private const int ElevatorCancelled = 999; + + private const int MaxElevatorOutputLines = 20; + + private const string ElevatorCancelledByUser = "The operation was canceled by the user"; + + private const int ElevationWaitMilliseconds = 60_000; + + private static readonly Lock ElevationLock = new(); + + private static Task? _runningElevation; + + private static bool _elevationLeftUnanswered; + + private static readonly string[] ShortcutExtensions = [".lnk", ".url"]; + + private static readonly Environment.SpecialFolder[] ShortcutFolders = + [ + Environment.SpecialFolder.DesktopDirectory, + Environment.SpecialFolder.CommonDesktopDirectory, + Environment.SpecialFolder.Programs, + Environment.SpecialFolder.CommonPrograms, + ]; + + public static IReadOnlyList? TEST_ShortcutRootsOverride; + + public static bool Delete(string shortcutPath) + { + if (TryDelete(shortcutPath, out bool accessDenied)) + return true; + + if (!accessDenied) + return false; + + DeleteElevated([shortcutPath]); + return !File.Exists(shortcutPath); + } + + public static void Delete(IReadOnlyList shortcutPaths) + { + List denied = []; + + foreach (string shortcutPath in shortcutPaths) + { + if (!TryDelete(shortcutPath, out bool accessDenied) && accessDenied) + denied.Add(shortcutPath); + } + + if (denied.Count > 0) + DeleteElevated(denied); + } + + private static bool TryDelete(string shortcutPath, out bool accessDenied) + { + accessDenied = false; + + for (int attempt = 0; attempt < 2; attempt++) + { + try + { + File.Delete(shortcutPath); + return true; + } + catch (UnauthorizedAccessException e) + { + if (attempt is 0 && TryClearReadOnlyAttribute(shortcutPath)) + continue; + + accessDenied = true; + Logger.Warn( + $"Not allowed to delete shortcut {{shortcutPath={shortcutPath}}}: {e.Message}" + ); + return false; + } + catch (Exception e) + { + Logger.Error( + $"Failed to delete shortcut {{shortcutPath={shortcutPath}}}: {e.Message}" + ); + return false; + } + } + + return false; + } + + private static bool TryClearReadOnlyAttribute(string shortcutPath) + { + try + { + FileAttributes attributes = File.GetAttributes(shortcutPath); + if (!attributes.HasFlag(FileAttributes.ReadOnly)) + return false; + + File.SetAttributes(shortcutPath, attributes & ~FileAttributes.ReadOnly); + return true; + } + catch (Exception) + { + return false; + } + } + + private static bool DeleteElevated(IReadOnlyList shortcutPaths) + { + if (!OperatingSystem.IsWindows()) + return false; + + if (TEST_ShortcutRootsOverride is not null) + { + Logger.Warn( + "The shortcut roots are overridden for testing, no elevation will be requested" + ); + return false; + } + + if (Settings.Get(Settings.K.ProhibitElevation)) + { + Logger.Warn("Elevation is prohibited, protected shortcuts will be left on disk"); + return false; + } + + if (CoreTools.IsAdministrator()) + return false; + + List targets = []; + foreach (string shortcutPath in shortcutPaths) + { + if (!IsRemovableShortcutPath(shortcutPath)) + { + Logger.Warn( + $"Refusing to elevate the deletion of {{shortcutPath={shortcutPath}}}, it is " + + "not a shortcut under a known desktop or Start Menu folder" + ); + continue; + } + + if (!targets.Contains(shortcutPath, StringComparer.OrdinalIgnoreCase)) + targets.Add(shortcutPath); + } + + if (targets.Count is 0) + return false; + + Task elevation; + lock (ElevationLock) + { + if (_elevationLeftUnanswered || _runningElevation is { IsCompleted: false }) + { + Logger.Warn( + "A UAC prompt to delete protected shortcuts is still waiting to be answered, " + + "no new one will be raised" + ); + return false; + } + + Logger.Info( + $"Relaunching UniGetUI elevated to delete {targets.Count} protected shortcut(s)" + ); + + elevation = Task.Run(() => RunElevatedDeletion(targets)); + _runningElevation = elevation; + } + + if (elevation.Wait(ElevationWaitMilliseconds)) + return elevation.GetAwaiter().GetResult(); + + lock (ElevationLock) + { + _elevationLeftUnanswered = true; + } + + elevation.ContinueWith( + _ => + { + lock (ElevationLock) + { + _elevationLeftUnanswered = false; + } + }, + TaskScheduler.Default + ); + + Logger.Warn( + "The UAC prompt to delete protected shortcuts was left unanswered, UniGetUI will stop " + + "waiting for it and will not raise another one until it is answered" + ); + return false; + } + + private enum ElevationResult + { + Succeeded, + Cancelled, + ElevatorUnusable, + } + + private static bool RunElevatedDeletion(IReadOnlyList targets) + { + if (CoreData.ElevatorPath.Length is 0) + { + Logger.Warn( + "No elevator is available, the deletion of protected shortcuts will be elevated " + + "through Windows instead, which cannot reuse cached administrator rights" + ); + return RunThroughWindows(targets); + } + + return RunThroughElevator(targets) switch + { + ElevationResult.Succeeded => true, + ElevationResult.Cancelled => false, + _ => RunThroughWindows(targets), + }; + } + + private static ElevationResult RunThroughElevator(IReadOnlyList targets) + { + try + { + using Process process = new(); + process.StartInfo = new ProcessStartInfo + { + FileName = CoreData.ElevatorPath, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + foreach ( + string argument in CoreData.ElevatorArgs.Split( + ' ', + StringSplitOptions.RemoveEmptyEntries + ) + ) + process.StartInfo.ArgumentList.Add(argument); + + process.StartInfo.ArgumentList.Add(CoreData.UniGetUIExecutableFile); + process.StartInfo.ArgumentList.Add(CliArgument); + foreach (string target in targets) + process.StartInfo.ArgumentList.Add(target); + + List output = []; + void Collect(object _, DataReceivedEventArgs line) + { + if (line.Data is null) + return; + + lock (output) + { + if (output.Count < MaxElevatorOutputLines) + output.Add(line.Data); + } + } + + process.OutputDataReceived += Collect; + process.ErrorDataReceived += Collect; + + CoreTools.PrepareForegroundForElevation(); + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + process.WaitForExit(); + + if (process.ExitCode is 0) + return ElevationResult.Succeeded; + + string details; + bool cancelledByUser; + lock (output) + { + details = output.Count is 0 ? "no output" : string.Join(" | ", output); + cancelledByUser = output.Any(line => + line.Contains(ElevatorCancelledByUser, StringComparison.OrdinalIgnoreCase) + ); + } + + if (process.ExitCode is ElevatorCancelled && cancelledByUser) + { + Logger.Warn( + "The elevator's UAC prompt to delete protected shortcuts was canceled, the " + + "shortcuts will be left on disk" + ); + return ElevationResult.Cancelled; + } + + Logger.Warn( + $"The elevator could not elevate the deletion of protected shortcuts (code " + + $"{process.ExitCode}: {details}), falling back to a UAC prompt raised by " + + "Windows" + ); + return ElevationResult.ElevatorUnusable; + } + catch (Exception e) + { + Logger.Error("Could not run the elevator to delete protected shortcuts"); + Logger.Error(e); + return ElevationResult.ElevatorUnusable; + } + } + + private static bool RunThroughWindows(IReadOnlyList targets) + { + try + { + using Process process = new(); + process.StartInfo = new ProcessStartInfo + { + FileName = CoreData.UniGetUIExecutableFile, + UseShellExecute = true, + CreateNoWindow = true, + Verb = "runas", + }; + + process.StartInfo.ArgumentList.Add(CliArgument); + foreach (string target in targets) + process.StartInfo.ArgumentList.Add(target); + + CoreTools.PrepareForegroundForElevation(); + process.Start(); + process.WaitForExit(); + + return process.ExitCode is 0; + } + catch (Win32Exception e) when (e.NativeErrorCode is ErrorCancelled) + { + Logger.Warn( + "The UAC prompt raised to delete protected shortcuts was canceled, the shortcuts " + + "will be left on disk" + ); + return false; + } + catch (Exception e) + { + Logger.Error("Could not relaunch UniGetUI elevated to delete protected shortcuts"); + Logger.Error(e); + return false; + } + } + + public static int DeleteAsElevatedInstance(IReadOnlyList shortcutPaths) + { + bool everythingDeleted = true; + + foreach (string shortcutPath in shortcutPaths) + { + if (!IsRemovableShortcutPath(shortcutPath)) + { + Logger.Error( + $"Refused to delete {{shortcutPath={shortcutPath}}}, it is not a shortcut " + + "under a known desktop or Start Menu folder" + ); + everythingDeleted = false; + continue; + } + + if (!TryDelete(shortcutPath, out _)) + everythingDeleted = false; + } + + return everythingDeleted ? 0 : 1; + } + + public static IReadOnlyList GetShortcutRoots() + { + if (TEST_ShortcutRootsOverride is not null) + return TEST_ShortcutRootsOverride; + + List roots = []; + foreach (Environment.SpecialFolder folder in ShortcutFolders) + { + string root = Environment.GetFolderPath(folder); + if (root.Length > 0) + roots.Add(root); + } + + return roots; + } + + public static bool IsRemovableShortcutPath(string shortcutPath) + { + if (string.IsNullOrWhiteSpace(shortcutPath)) + return false; + + if (shortcutPath.Contains('*') || shortcutPath.Contains('?')) + return false; + + string fullPath; + try + { + fullPath = Path.TrimEndingDirectorySeparator(Path.GetFullPath(shortcutPath)); + } + catch (Exception) + { + return false; + } + + string fileName = Path.GetFileName(fullPath); + if (fileName.Contains(':')) + return false; + + if ( + !ShortcutExtensions.Contains( + Path.GetExtension(fileName), + StringComparer.OrdinalIgnoreCase + ) + ) + return false; + + foreach (string root in GetShortcutRoots()) + { + string fullRoot; + try + { + fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + } + catch (Exception) + { + continue; + } + + if ( + !fullPath.StartsWith( + fullRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase + ) + ) + continue; + + return !CrossesReparsePoint(fullRoot, fullPath); + } + + return false; + } + + private static bool CrossesReparsePoint(string fullRoot, string fullPath) + { + try + { + string? current = Path.GetDirectoryName(fullPath); + while ( + current is not null + && !string.Equals( + Path.TrimEndingDirectorySeparator(current), + fullRoot, + StringComparison.OrdinalIgnoreCase + ) + ) + { + DirectoryInfo directory = new(current); + if (directory.Exists && directory.Attributes.HasFlag(FileAttributes.ReparsePoint)) + return true; + + current = Path.GetDirectoryName(current); + } + + return current is null; + } + catch (Exception) + { + return true; + } + } +} diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/DesktopShortcutsDatabase.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/DesktopShortcutsDatabase.cs index b993169cd8..cfe6dd7e34 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/DesktopShortcutsDatabase.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/DesktopShortcutsDatabase.cs @@ -1,5 +1,6 @@ using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; namespace UniGetUI.PackageEngine.Classes.Packages.Classes; @@ -80,16 +81,15 @@ public static bool Remove(string shortcutPath) public static bool DeleteFromDisk(string shortcutPath) { Logger.Info("Deleting shortcut " + shortcutPath); - try - { - File.Delete(shortcutPath); - return true; - } - catch (Exception e) - { - Logger.Error($"Failed to delete shortcut {{shortcutPath={shortcutPath}}}: {e.Message}"); - return false; - } + return ShortcutFileRemover.Delete(shortcutPath); + } + + public static void DeleteFromDisk(IReadOnlyList shortcutPaths) + { + foreach (string shortcutPath in shortcutPaths) + Logger.Info("Deleting shortcut " + shortcutPath); + + ShortcutFileRemover.Delete(shortcutPaths); } /// @@ -204,6 +204,7 @@ public static void HandleNewShortcuts(IReadOnlyList PreviousShortCutList bool DeleteUnknownShortcuts = Settings.Get(Settings.K.RemoveAllDesktopShortcuts); HashSet PreviousShortcuts = [.. PreviousShortCutList]; List CurrentShortcuts = GetShortcutsOnDisk(); + List ShortcutsToDelete = []; foreach (string shortcut in CurrentShortcuts) { @@ -216,7 +217,7 @@ public static void HandleNewShortcuts(IReadOnlyList PreviousShortCutList { // If a shortcut is set to be deleted, delete it, // even when it was not created during an UniGetUI operation - DeleteFromDisk(shortcut); + ShortcutsToDelete.Add(shortcut); } else if (status is Status.Unknown) { @@ -233,7 +234,7 @@ public static void HandleNewShortcuts(IReadOnlyList PreviousShortCutList $"New shortcut {shortcut} will be set for deletion (this shortcut was never seen before)" ); AddToDatabase(shortcut, Status.Delete); - DeleteFromDisk(shortcut); + ShortcutsToDelete.Add(shortcut); } else { @@ -246,5 +247,8 @@ public static void HandleNewShortcuts(IReadOnlyList PreviousShortCutList } } } + + if (ShortcutsToDelete.Count > 0) + DeleteFromDisk(ShortcutsToDelete); } } diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/StartMenuShortcutsDatabase.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/StartMenuShortcutsDatabase.cs index 22c996a6e0..773350884e 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/StartMenuShortcutsDatabase.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Packages/Classes/StartMenuShortcutsDatabase.cs @@ -1,6 +1,7 @@ using System.Text; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; +using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Interfaces; namespace UniGetUI.PackageEngine.Classes.Packages.Classes; @@ -609,6 +610,9 @@ public static int RebaseRelocations(string packageId) public static int HandleNewShortcuts(IPackage package, IReadOnlyList previousShortcuts) { + List shortcutsToDelete = []; + int handled; + lock (DatabaseLock) { if (!OperatingSystem.IsWindows()) @@ -630,7 +634,7 @@ public static int HandleNewShortcuts(IPackage package, IReadOnlyList pre // Recorded destinations are only replayed while the package still has a folder: // dropping the folder has to stop the relocations, not just the new ones. - int handled = rule is null ? 0 : ReplayRelocations(packageId); + handled = rule is null ? 0 : ReplayRelocations(packageId); HashSet previous = new(previousShortcuts, StringComparer.OrdinalIgnoreCase); foreach (string shortcut in GetShortcutsOnDisk()) @@ -639,8 +643,7 @@ public static int HandleNewShortcuts(IPackage package, IReadOnlyList pre if (status is Status.Delete) { - if (DeleteFromDisk(shortcut)) - handled++; + shortcutsToDelete.Add(shortcut); continue; } @@ -688,9 +691,15 @@ public static int HandleNewShortcuts(IPackage package, IReadOnlyList pre if (askAboutNewShortcuts && status is Status.Unknown) MarkPending(packageId, shortcut); } + } - return handled; + if (shortcutsToDelete.Count > 0) + { + DeleteFromDisk(shortcutsToDelete); + handled += shortcutsToDelete.Count(shortcut => !File.Exists(shortcut)); } + + return handled; } public static IReadOnlyList FindRelocatableShortcuts( @@ -931,18 +940,24 @@ public static int CleanupForPackage(string packageId) public static bool DeleteFromDisk(string shortcutPath) { Logger.Info("Deleting Start Menu shortcut " + shortcutPath); - try - { - File.Delete(shortcutPath); - PruneEmptyDirectories(Path.GetDirectoryName(shortcutPath)); - return true; - } - catch (Exception e) - { - Logger.Error( - $"Failed to delete the Start Menu shortcut {{shortcutPath={shortcutPath}}}: {e.Message}" - ); + if (!ShortcutFileRemover.Delete(shortcutPath)) return false; + + PruneEmptyDirectories(Path.GetDirectoryName(shortcutPath)); + return true; + } + + public static void DeleteFromDisk(IReadOnlyList shortcutPaths) + { + foreach (string shortcutPath in shortcutPaths) + Logger.Info("Deleting Start Menu shortcut " + shortcutPath); + + ShortcutFileRemover.Delete(shortcutPaths); + + foreach (string shortcutPath in shortcutPaths) + { + if (!File.Exists(shortcutPath)) + PruneEmptyDirectories(Path.GetDirectoryName(shortcutPath)); } } diff --git a/src/UniGetUI.Tests/CLIHandlerTests.cs b/src/UniGetUI.Tests/CLIHandlerTests.cs index 3a3f5758f0..8a0b8415a9 100644 --- a/src/UniGetUI.Tests/CLIHandlerTests.cs +++ b/src/UniGetUI.Tests/CLIHandlerTests.cs @@ -2,6 +2,7 @@ using UniGetUI.Core.Data; using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.SettingsEngine.SecureSettings; +using UniGetUI.Core.Tools; using UniGetUI.Shared; namespace UniGetUI.Tests; @@ -287,4 +288,68 @@ public void EnableAndDisableSecureSettingForUser_MutateSecureSettings() ) ); } + + [Fact] + public void DeleteShortcuts_RemovesShortcutsUnderAKnownRootWithoutManglingTheirNames() + { + string root = Path.Combine(_testRoot, "Desktop"); + Directory.CreateDirectory(root); + ShortcutFileRemover.TEST_ShortcutRootsOverride = [root]; + + try + { + string shortcut = Path.Combine(root, "'LibreOffice' 26.8.lnk"); + File.WriteAllText(shortcut, "shortcut"); + + int? result = SharedPreUiCommandDispatcher.TryHandle( + ["unigetui", ShortcutFileRemover.CliArgument, shortcut], + SharedPreUiCommandDispatcher.WindowsCliExitCodes + ); + + Assert.Equal(SharedPreUiCommandDispatcher.WindowsCliExitCodes.Success, result); + Assert.False(File.Exists(shortcut)); + } + finally + { + ShortcutFileRemover.TEST_ShortcutRootsOverride = null; + } + } + + [Fact] + public void DeleteShortcuts_RefusesPathsOutsideEveryKnownRoot() + { + string root = Path.Combine(_testRoot, "Desktop"); + Directory.CreateDirectory(root); + ShortcutFileRemover.TEST_ShortcutRootsOverride = [root]; + + try + { + string outside = Path.Combine(_testRoot, "Payload.lnk"); + File.WriteAllText(outside, "payload"); + + int? result = SharedPreUiCommandDispatcher.TryHandle( + ["unigetui", ShortcutFileRemover.CliArgument, outside], + SharedPreUiCommandDispatcher.WindowsCliExitCodes + ); + + Assert.Equal(SharedPreUiCommandDispatcher.WindowsCliExitCodes.Failed, result); + Assert.True(File.Exists(outside)); + } + finally + { + ShortcutFileRemover.TEST_ShortcutRootsOverride = null; + } + } + + [Fact] + public void DeleteShortcuts_ReportsAnInvalidParameterWhenNoPathIsGiven() + { + Assert.Equal( + SharedPreUiCommandDispatcher.WindowsCliExitCodes.InvalidParameter, + SharedPreUiCommandDispatcher.TryHandle( + ["unigetui", ShortcutFileRemover.CliArgument], + SharedPreUiCommandDispatcher.WindowsCliExitCodes + ) + ); + } } From f94abb879f7985eaa7786b1aa82e306a085fb002 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 11 Sep 2026 14:29:15 -0400 Subject: [PATCH 2/3] Harden the elevated shortcut deletion against redirected paths Addresses both review comments on the elevated deletion path. --- .../ShortcutFileRemoverTests.cs | 88 +++++++ .../ShortcutFileRemover.cs | 217 ++++++++++++++++-- 2 files changed, 291 insertions(+), 14 deletions(-) diff --git a/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs b/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs index 9c598112dc..d7cdd19dfd 100644 --- a/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs +++ b/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs @@ -163,6 +163,94 @@ public void TheElevatedInstanceRemovesShortcutsUnderAKnownRoot() Assert.False(File.Exists(shortcut)); } + [Fact] + public void TheElevatedInstanceRefusesAShortcutReachedThroughAJunction() + { + if (!OperatingSystem.IsWindows()) + return; + + string outside = CreateFile(_outsideRoot, "Payload.lnk"); + string junction = Path.Combine(_root, "Vendor"); + if (!TryCreateJunction(junction, _outsideRoot)) + return; + + try + { + Assert.Equal( + 1, + ShortcutFileRemover.DeleteAsElevatedInstance( + [Path.Combine(junction, "Payload.lnk")] + ) + ); + Assert.True(File.Exists(outside)); + } + finally + { + Directory.Delete(junction); + } + } + + [Fact] + public void TheElevatedInstanceRemovesAShortcutInsideARealSubfolder() + { + string shortcut = CreateFile(Path.Combine(_root, "Vendor"), "Writer.lnk"); + + Assert.Equal(0, ShortcutFileRemover.DeleteAsElevatedInstance([shortcut])); + Assert.False(File.Exists(shortcut)); + } + + [Fact] + public void TheElevatedInstanceTreatsAMissingShortcutAsDeleted() + { + Assert.Equal( + 0, + ShortcutFileRemover.DeleteAsElevatedInstance([Path.Combine(_root, "Missing.lnk")]) + ); + } + + [Fact] + public void TheElevatedInstanceReportsAShortcutHeldOpenWithoutDeleteSharing() + { + string shortcut = CreateFile(_root, "Locked.lnk"); + using var handle = new FileStream( + shortcut, + FileMode.Open, + FileAccess.Read, + FileShare.Read + ); + + Assert.Equal(1, ShortcutFileRemover.DeleteAsElevatedInstance([shortcut])); + Assert.True(File.Exists(shortcut)); + } + + [Fact] + public void TheElevatedInstanceRemovesAReadOnlyShortcut() + { + string shortcut = CreateFile(_root, "ReadOnlyElevated.lnk"); + File.SetAttributes(shortcut, File.GetAttributes(shortcut) | FileAttributes.ReadOnly); + + Assert.Equal(0, ShortcutFileRemover.DeleteAsElevatedInstance([shortcut])); + Assert.False(File.Exists(shortcut)); + } + + private static bool TryCreateJunction(string link, string target) + { + using var process = System.Diagnostics.Process.Start( + new System.Diagnostics.ProcessStartInfo + { + FileName = "cmd.exe", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + ArgumentList = { "/c", "mklink", "/J", link, target }, + } + ); + + process!.WaitForExit(); + return process.ExitCode is 0 && Directory.Exists(link); + } + [Fact] public void TheElevatedInstanceRefusesPathsItDoesNotManage() { diff --git a/src/UniGetUI.Core.Tools/ShortcutFileRemover.cs b/src/UniGetUI.Core.Tools/ShortcutFileRemover.cs index 4bcfafe1b1..f87e35141e 100644 --- a/src/UniGetUI.Core.Tools/ShortcutFileRemover.cs +++ b/src/UniGetUI.Core.Tools/ShortcutFileRemover.cs @@ -1,5 +1,7 @@ using System.ComponentModel; using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; using UniGetUI.Core.Data; using UniGetUI.Core.Logging; using UniGetUI.Core.SettingsEngine; @@ -18,6 +20,21 @@ public static class ShortcutFileRemover private const string ElevatorCancelledByUser = "The operation was canceled by the user"; + private const uint DeleteAccess = 0x00010000; + private const uint FileReadAttributes = 0x00000080; + private const uint ShareReadWrite = 0x00000001 | 0x00000002; + private const uint OpenExisting = 3; + private const uint OpenReparsePoint = 0x00200000; + private const uint FileNameNormalized = 0x00000000; + private const int FileDispositionInfo = 4; + private const int FileDispositionInfoEx = 21; + private const uint DispositionDelete = 0x00000001; + private const uint DispositionIgnoreReadOnly = 0x00000010; + private const int ErrorFileNotFound = 2; + private const int ErrorPathNotFound = 3; + private const string ExtendedPathPrefix = @"\\?\"; + private const string ExtendedUncPathPrefix = @"\\?\UNC\"; + private const int ElevationWaitMilliseconds = 60_000; private static readonly Lock ElevationLock = new(); @@ -26,6 +43,8 @@ public static class ShortcutFileRemover private static bool _elevationLeftUnanswered; + private static volatile bool _elevatorUnusable; + private static readonly string[] ShortcutExtensions = [".lnk", ".url"]; private static readonly Environment.SpecialFolder[] ShortcutFolders = @@ -77,7 +96,11 @@ private static bool TryDelete(string shortcutPath, out bool accessDenied) } catch (UnauthorizedAccessException e) { - if (attempt is 0 && TryClearReadOnlyAttribute(shortcutPath)) + if ( + attempt is 0 + && IsRemovableShortcutPath(shortcutPath) + && TryClearReadOnlyAttribute(shortcutPath) + ) continue; accessDenied = true; @@ -206,26 +229,28 @@ private enum ElevationResult { Succeeded, Cancelled, + Failed, ElevatorUnusable, } private static bool RunElevatedDeletion(IReadOnlyList targets) { - if (CoreData.ElevatorPath.Length is 0) + if (CoreData.ElevatorPath.Length is 0 || _elevatorUnusable) { Logger.Warn( - "No elevator is available, the deletion of protected shortcuts will be elevated " - + "through Windows instead, which cannot reuse cached administrator rights" + "No usable elevator is available, the deletion of protected shortcuts will be " + + "elevated through Windows instead, which cannot reuse cached administrator " + + "rights" ); return RunThroughWindows(targets); } - return RunThroughElevator(targets) switch - { - ElevationResult.Succeeded => true, - ElevationResult.Cancelled => false, - _ => RunThroughWindows(targets), - }; + ElevationResult result = RunThroughElevator(targets); + if (result is not ElevationResult.ElevatorUnusable) + return result is ElevationResult.Succeeded; + + _elevatorUnusable = true; + return RunThroughWindows(targets); } private static ElevationResult RunThroughElevator(IReadOnlyList targets) @@ -291,6 +316,15 @@ void Collect(object _, DataReceivedEventArgs line) ); } + if (process.ExitCode <= 0) + { + Logger.Error( + $"The elevated instance could not delete every protected shortcut (code " + + $"{process.ExitCode}: {details})" + ); + return ElevationResult.Failed; + } + if (process.ExitCode is ElevatorCancelled && cancelledByUser) { Logger.Warn( @@ -370,13 +404,112 @@ public static int DeleteAsElevatedInstance(IReadOnlyList shortcutPaths) continue; } - if (!TryDelete(shortcutPath, out _)) + if (!DeleteVerifiedShortcut(shortcutPath)) everythingDeleted = false; } return everythingDeleted ? 0 : 1; } + private static bool DeleteVerifiedShortcut(string shortcutPath) + { + if (!OperatingSystem.IsWindows()) + return TryDelete(shortcutPath, out _); + + try + { + using SafeFileHandle handle = CreateFileW( + shortcutPath, + DeleteAccess | FileReadAttributes, + ShareReadWrite, + IntPtr.Zero, + OpenExisting, + OpenReparsePoint, + IntPtr.Zero + ); + + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + if (error is ErrorFileNotFound or ErrorPathNotFound) + return true; + + Logger.Error( + $"Could not open the shortcut {{shortcutPath={shortcutPath}}} for deletion, " + + $"Windows returned error {error}" + ); + return false; + } + + string? resolvedPath = GetResolvedPath(handle); + if (resolvedPath is null || !IsRemovableShortcutPath(resolvedPath)) + { + Logger.Error( + $"Refused to delete {{shortcutPath={shortcutPath}}}, it resolves to " + + $"{resolvedPath ?? "an unknown path"}, which is not a shortcut under a " + + "known desktop or Start Menu folder" + ); + return false; + } + + uint disposition = DispositionDelete | DispositionIgnoreReadOnly; + if ( + SetFileInformationByHandle( + handle, + FileDispositionInfoEx, + ref disposition, + sizeof(uint) + ) + ) + return true; + + byte deleteFile = 1; + if ( + SetFileInformationByHandle( + handle, + FileDispositionInfo, + ref deleteFile, + sizeof(byte) + ) + ) + return true; + + Logger.Error( + $"Could not delete the shortcut {{shortcutPath={shortcutPath}}}, Windows returned " + + $"error {Marshal.GetLastWin32Error()}" + ); + return false; + } + catch (Exception e) + { + Logger.Error($"Failed to delete the shortcut {{shortcutPath={shortcutPath}}}"); + Logger.Error(e); + return false; + } + } + + private static string? GetResolvedPath(SafeFileHandle handle) + { + char[] buffer = new char[1024]; + uint length = GetFinalPathNameByHandleW( + handle, + buffer, + (uint)buffer.Length, + FileNameNormalized + ); + + if (length is 0 || length >= buffer.Length) + return null; + + string path = new(buffer, 0, (int)length); + if (path.StartsWith(ExtendedUncPathPrefix, StringComparison.Ordinal)) + return @"\\" + path[ExtendedUncPathPrefix.Length..]; + + return path.StartsWith(ExtendedPathPrefix, StringComparison.Ordinal) + ? path[ExtendedPathPrefix.Length..] + : path; + } + public static IReadOnlyList GetShortcutRoots() { if (TEST_ShortcutRootsOverride is not null) @@ -386,13 +519,31 @@ public static IReadOnlyList GetShortcutRoots() foreach (Environment.SpecialFolder folder in ShortcutFolders) { string root = Environment.GetFolderPath(folder); - if (root.Length > 0) - roots.Add(root); + if (root.Length is 0) + continue; + + AddRoot(roots, root); + + try + { + if ( + Directory.ResolveLinkTarget(root, returnFinalTarget: true) is { } target + && Path.GetDirectoryName(target.FullName) is not null + ) + AddRoot(roots, target.FullName); + } + catch (Exception) { } } return roots; } + private static void AddRoot(List roots, string root) + { + if (!roots.Contains(root, StringComparer.OrdinalIgnoreCase)) + roots.Add(root); + } + public static bool IsRemovableShortcutPath(string shortcutPath) { if (string.IsNullOrWhiteSpace(shortcutPath)) @@ -443,7 +594,8 @@ public static bool IsRemovableShortcutPath(string shortcutPath) ) continue; - return !CrossesReparsePoint(fullRoot, fullPath); + if (!CrossesReparsePoint(fullRoot, fullPath)) + return true; } return false; @@ -477,4 +629,41 @@ current is not null return true; } } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile + ); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW( + SafeFileHandle hFile, + [Out] char[] lpszFilePath, + uint cchFilePath, + uint dwFlags + ); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle( + SafeFileHandle hFile, + int FileInformationClass, + ref uint lpFileInformation, + uint dwBufferSize + ); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle( + SafeFileHandle hFile, + int FileInformationClass, + ref byte lpFileInformation, + uint dwBufferSize + ); } From c6e77f532f6762d70a8d0c592b482cf40a8a5521 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 11 Sep 2026 14:55:11 -0400 Subject: [PATCH 3/3] Fail the reparse-point test when its junction cannot be created --- .../ShortcutFileRemoverTests.cs | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs b/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs index d7cdd19dfd..0e84422019 100644 --- a/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs +++ b/src/UniGetUI.Core.Tools.Tests/ShortcutFileRemoverTests.cs @@ -171,8 +171,13 @@ public void TheElevatedInstanceRefusesAShortcutReachedThroughAJunction() string outside = CreateFile(_outsideRoot, "Payload.lnk"); string junction = Path.Combine(_root, "Vendor"); - if (!TryCreateJunction(junction, _outsideRoot)) - return; + string mklinkOutput = CreateJunction(junction, _outsideRoot); + + Assert.True( + Directory.Exists(junction), + "The junction that redirects the shortcut out of its root could not be created, so " + + $"the reparse point protection was left unverified: {mklinkOutput}" + ); try { @@ -190,6 +195,30 @@ public void TheElevatedInstanceRefusesAShortcutReachedThroughAJunction() } } + [Fact] + public void AnOpenShortcutPinsEveryDirectoryAboveIt() + { + if (!OperatingSystem.IsWindows()) + return; + + string vendor = Path.Combine(_root, "Vendor"); + string shortcut = CreateFile(vendor, "App.lnk"); + + using var handle = new FileStream( + shortcut, + FileMode.Open, + FileAccess.Read, + FileShare.Read | FileShare.Write + ); + + Assert.ThrowsAny(() => File.Delete(shortcut)); + Assert.ThrowsAny(() => Directory.Move(vendor, vendor + "-swapped")); + Assert.ThrowsAny(() => Directory.Move(_root, _root + "-swapped")); + + Assert.True(File.Exists(shortcut)); + Assert.True(Directory.Exists(vendor)); + } + [Fact] public void TheElevatedInstanceRemovesAShortcutInsideARealSubfolder() { @@ -233,7 +262,7 @@ public void TheElevatedInstanceRemovesAReadOnlyShortcut() Assert.False(File.Exists(shortcut)); } - private static bool TryCreateJunction(string link, string target) + private static string CreateJunction(string link, string target) { using var process = System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo @@ -247,8 +276,9 @@ private static bool TryCreateJunction(string link, string target) } ); - process!.WaitForExit(); - return process.ExitCode is 0 && Directory.Exists(link); + string output = process!.StandardOutput.ReadToEnd() + process.StandardError.ReadToEnd(); + process.WaitForExit(); + return $"mklink exited with {process.ExitCode}: {output.Trim()}"; } [Fact]