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(