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(