From 7e5638338e0e489a86e6f168515b46d48c61aa22 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Fri, 21 Aug 2026 20:54:42 +1000 Subject: [PATCH] Stop "Purge verified files" from killing the tray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things had to be true for a purge to take the process down, and both were. Inner ran on a bare Thread with no try/catch, so anything it threw was an unhandled exception on a non-UI thread — process gone, along with every pending move, every pending delete, and the whole inline queue when this tray owns it. And it reliably threw. Directory.GetFiles with SearchOption.AllDirectories is the compatibility enumeration: IgnoreInaccessible is false and reparse points are followed. The folder dialog invites the user to pick a profile folder or a drive root, and both hold a deny-ACL junction — Application Data, $Recycle.Bin — so the scan hit UnauthorizedAccessException before it had looked at one file. Wrap the thread body, and enumerate with EnumerationOptions that ignore what cannot be read and skip reparse points. The scan is now extracted as Find so a test can point it at a directory with a denied subdirectory, which is the shape that failed. --- src/DiffEngineTray.Tests/FilePurgerTest.cs | 34 +++++++++++++++++ src/DiffEngineTray.Tests/GlobalUsings.cs | 2 + src/DiffEngineTray/FilePurger.cs | 43 +++++++++++++++++++--- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/src/DiffEngineTray.Tests/FilePurgerTest.cs b/src/DiffEngineTray.Tests/FilePurgerTest.cs index 52cfb8f9..a20d858a 100644 --- a/src/DiffEngineTray.Tests/FilePurgerTest.cs +++ b/src/DiffEngineTray.Tests/FilePurgerTest.cs @@ -1,5 +1,39 @@ public class FilePurgerTest { + /// + /// The dialog lets the user pick a profile folder or a drive root, and both hold something + /// the scan cannot read. SearchOption.AllDirectories threw on the first one, out of a bare + /// Thread with nothing catching it, which took the whole tray down. + /// + [Test] + public async Task Find_SkipsInaccessibleDirectories() + { + var root = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), $"FilePurgerTest_{Guid.NewGuid()}")); + var denied = root.CreateSubdirectory("denied"); + await File.WriteAllTextAsync(Path.Combine(root.FullName, "a.verified.txt"), "content"); + + var security = denied.GetAccessControl(); + var rule = new FileSystemAccessRule( + WindowsIdentity.GetCurrent().User!, + FileSystemRights.ListDirectory, + AccessControlType.Deny); + security.AddAccessRule(rule); + denied.SetAccessControl(security); + try + { + var found = FilePurger.Find(root.FullName); + + await Assert.That(found).HasSingleItem(); + await Assert.That(Path.GetFileName(found[0])).IsEqualTo("a.verified.txt"); + } + finally + { + security.RemoveAccessRule(rule); + denied.SetAccessControl(security); + root.Delete(true); + } + } [Test] public async Task DeleteSucceeds_WhenFileNotLocked() { diff --git a/src/DiffEngineTray.Tests/GlobalUsings.cs b/src/DiffEngineTray.Tests/GlobalUsings.cs index 9ed6ec7b..1a0a642a 100644 --- a/src/DiffEngineTray.Tests/GlobalUsings.cs +++ b/src/DiffEngineTray.Tests/GlobalUsings.cs @@ -1,6 +1,8 @@ global using System.Diagnostics; global using System.Net; global using System.Net.Sockets; +global using System.Security.AccessControl; +global using System.Security.Principal; global using EmptyFiles; [assembly: ParallelLimiter] diff --git a/src/DiffEngineTray/FilePurger.cs b/src/DiffEngineTray/FilePurger.cs index 15c60ba1..09248a49 100644 --- a/src/DiffEngineTray/FilePurger.cs +++ b/src/DiffEngineTray/FilePurger.cs @@ -2,11 +2,28 @@ static class FilePurger { public static void Launch() { - var thread = new Thread(Inner); + var thread = new Thread(Run); thread.SetApartmentState(ApartmentState.STA); thread.Start(); } + /// + /// Nothing above this catches. An unhandled exception on a bare Thread takes the process down, + /// and the process is the tray - so a purge that threw took every pending move, delete and, if + /// this tray owns the queue, every pending inline snapshot with it. + /// + static void Run() + { + try + { + Inner(); + } + catch (Exception exception) + { + ExceptionHandler.Handle("Failed to purge verified files.", exception); + } + } + static void Inner() { using var dialog = new FolderBrowserDialog(); @@ -19,10 +36,7 @@ static void Inner() return; } - var verifiedFiles = Directory.GetFiles(path, "*.verified.*", SearchOption.AllDirectories); - var receivedFiles = Directory.GetFiles(path, "*.received.*", SearchOption.AllDirectories); - - var files = verifiedFiles.Concat(receivedFiles).ToArray(); + var files = Find(path); if (files.Length == 0) { @@ -36,6 +50,25 @@ static void Inner() } } + /// + /// SearchOption.AllDirectories is the compatibility enumeration: it does not ignore what it + /// cannot read, and it follows reparse points. The dialog lets the user pick a profile folder + /// or a drive root, either of which holds a deny-ACL junction - Application Data, + /// $Recycle.Bin - so the scan reliably threw UnauthorizedAccessException before it had looked + /// at a single file. + /// + static readonly EnumerationOptions enumeration = new() + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint + }; + + internal static string[] Find(string path) => + Directory.GetFiles(path, "*.verified.*", enumeration) + .Concat(Directory.GetFiles(path, "*.received.*", enumeration)) + .ToArray(); + static bool Confirm(string[] files) { var result = AskQuestion(