diff --git a/AuthoringTests.md b/AuthoringTests.md index 3bbe9db3e1..d5b6b88a64 100644 --- a/AuthoringTests.md +++ b/AuthoringTests.md @@ -92,6 +92,22 @@ Mac: ## How to Write a Functional Test +### White-box Functional Tests + +White-box functional tests exercise product APIs with real operating-system dependencies, such as NTFS handles or a ProjFS provider. +They belong in `GVFS.FunctionalTests`, not the unit-test project. +Keep Windows-specific fixtures under `Windows\Tests\WhiteBox` in the `GVFS.FunctionalTests.Tests.WhiteBox` namespace. +Mark each fixture with `[Category(Categories.WhiteBox)]` and document it as a white-box test in its class summary. + +Each fixture owns its temporary filesystem roots and releases its native resources. +Do not mix white-box cases into black-box fixtures. +Keep black-box tests independent of product-code APIs. + +For provider notifications, run client operations in a separate test process. +The backup fixture uses a private test-runner mode and a live-file control to verify notification delivery. + +### Black-box Functional Tests + Each piece of functionality that we add to VFS for Git should have corresponding functional tests that clone a repo, mount the filesystem, and use existing tools and filesystem APIs to interact with the virtual repo. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 639e1cfeb1..ff6f8de7ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -279,10 +279,14 @@ The design review process is as follows: Comprehensive tests are essential for maintaining the health and quality of the product. For more details on writing tests see [Authoring Tests](https://github.com/Microsoft/VFSForGit/blob/master/AuthoringTests.md). -- *Functional tests are black-box tests and should not build against any VFS4G product code* +- *Keep black-box functional tests independent of VFS4G product code* Keeping the code separate helps ensure that bugs in the product code do not compromise the integrity of the functional tests. + White-box functional tests can reference product code when they need native filesystem or provider integration. + Keep them in separate fixtures under `Tests.WhiteBox` and mark them with `Categories.WhiteBox`. + See [White-box Functional Tests](AuthoringTests.md#white-box-functional-tests) for their scope and location. + ### C# Unit Tests - *Add `ExceptionExpected` to unit tests that are expected to have exceptions* diff --git a/GVFS/GVFS.FunctionalTests/Categories.cs b/GVFS/GVFS.FunctionalTests/Categories.cs index 2aea957ed7..1b75236edb 100644 --- a/GVFS/GVFS.FunctionalTests/Categories.cs +++ b/GVFS/GVFS.FunctionalTests/Categories.cs @@ -5,5 +5,6 @@ public static class Categories public const string FastFetch = "FastFetch"; public const string GitCommands = "GitCommands"; public const string SkipInCI = "SkipInCI"; + public const string WhiteBox = "WhiteBox"; } } diff --git a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj index 3e85e4052e..7f1f240e12 100644 --- a/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj +++ b/GVFS/GVFS.FunctionalTests/GVFS.FunctionalTests.csproj @@ -8,6 +8,8 @@ + + @@ -15,6 +17,7 @@ + false diff --git a/GVFS/GVFS.FunctionalTests/Program.cs b/GVFS/GVFS.FunctionalTests/Program.cs index d74c70eb80..7165b74179 100644 --- a/GVFS/GVFS.FunctionalTests/Program.cs +++ b/GVFS/GVFS.FunctionalTests/Program.cs @@ -14,6 +14,12 @@ public class Program { public static void Main(string[] args) { + if (args.Length > 0 && args[0] == Tests.WhiteBox.DehydrateBackupProcess.Switch) + { + Environment.ExitCode = Tests.WhiteBox.DehydrateBackupProcess.Run(args); + return; + } + Properties.Settings.Default.Initialize(); GVFSPlatformLoader.Initialize(); diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/DehydrateBackupDeletionTests.cs b/GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/DehydrateBackupDeletionTests.cs new file mode 100644 index 0000000000..f52fe73465 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/DehydrateBackupDeletionTests.cs @@ -0,0 +1,386 @@ +using Microsoft.Windows.ProjFS; +using NUnit.Framework; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; + +namespace GVFS.FunctionalTests.Tests.WhiteBox +{ + /// + /// White-box tests for backup deletion with a real ProjFS provider. + /// + [TestFixture] + [Category(Categories.WhiteBox)] + public class DehydrateBackupDeletionTests + { + private string enlistment; + + [SetUp] + public void SetUp() + { + this.enlistment = Path.Combine(Path.GetTempPath(), nameof(DehydrateBackupDeletionTests) + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(this.enlistment); + } + + [TearDown] + public void TearDown() + { + Directory.Delete(this.enlistment, recursive: true); + } + + [TestCase("control")] + [TestCase("delete")] + public void RejectsHelperRootsOutsideTheDisposableFixture(string operation) + { + string root = Path.Combine(this.enlistment, "wrong"); + string source = Path.Combine(root, "src"); + Directory.CreateDirectory(source); + string sentinel = Path.Combine(source, DehydrateBackupProcess.NotificationControl); + File.WriteAllText(sentinel, "Keep"); + string backup = Path.Combine(root, "dehydrate_backup", "20261030_101814"); + Directory.CreateDirectory(backup); + string backupSentinel = Path.Combine(backup, "keep.txt"); + File.WriteAllText(backupSentinel, "Keep backup"); + Assert.Throws(() => DehydrateBackupProcess.Run( + new[] { DehydrateBackupProcess.Switch, operation, root, operation == "control" ? string.Empty : "20261030_101814" })); + Assert.That(File.ReadAllText(sentinel), Is.EqualTo("Keep")); + Assert.That(File.ReadAllText(backupSentinel), Is.EqualTo("Keep backup")); + } + + [TestCase(false)] + [TestCase(true)] + public void RejectsControlAliasesWithoutDeletingTheirTarget(bool rootAlias) + { + string outside = Path.Combine(this.enlistment, "outside"); + string source = Path.Combine(this.enlistment, "src"); + Directory.CreateDirectory(rootAlias ? source : outside); + string sentinel = Path.Combine(rootAlias ? source : outside, DehydrateBackupProcess.NotificationControl); + File.WriteAllText(sentinel, "Keep"); + string alias = rootAlias + ? Path.Combine(Path.GetTempPath(), nameof(DehydrateBackupDeletionTests) + Guid.NewGuid().ToString("N")) + : source; + Directory.CreateSymbolicLink(alias, rootAlias ? this.enlistment : outside); + try + { + Assert.Throws(() => DehydrateBackupProcess.Run( + new[] { DehydrateBackupProcess.Switch, "control", rootAlias ? alias : this.enlistment, string.Empty })); + Assert.That(File.ReadAllText(sentinel), Is.EqualTo("Keep")); + } + finally + { + Directory.Delete(alias); + } + } + + [TestCase(false)] + [TestCase(true)] + public void RejectsHelperParentAndGuidViolations(bool wrongParent) + { + string root = wrongParent + ? Path.Combine(this.enlistment, nameof(DehydrateBackupDeletionTests) + Guid.NewGuid().ToString("N")) + : Path.Combine(Path.GetTempPath(), nameof(DehydrateBackupDeletionTests) + Guid.NewGuid().ToString("N") + "-invalid"); + Assert.Throws(() => DehydrateBackupProcess.Run( + new[] { DehydrateBackupProcess.Switch, "control", root, string.Empty })); + } + + [TestCase(false)] + [TestCase(true)] + public void RejectsMalformedHelperArguments(bool wrongOperation) + { + string[] args = wrongOperation + ? new[] { DehydrateBackupProcess.Switch, "invalid", this.enlistment, string.Empty } + : new[] { DehydrateBackupProcess.Switch, "control", this.enlistment }; + Assert.Throws(() => DehydrateBackupProcess.Run(args)); + } + + [TestCase(false)] + [TestCase(true)] + public void DeletesMovedPhysicalEntriesWithoutStoppingTheProvider(bool fullBackup) + { + const string BackupName = "20261030_101814"; + string enlistment = this.enlistment; + string root = Path.Combine(enlistment, "src"); + string backup = Path.Combine(enlistment, "dehydrate_backup", BackupName); + Directory.CreateDirectory(root); + Directory.CreateDirectory(backup); + Projection provider = new Projection(root); + using (provider) + { + try + { + provider.Start(); + Assert.That(provider.WritePlaceholder("tree"), Is.EqualTo(HResult.Ok)); + Assert.That(provider.WritePlaceholder(@"tree\cached"), Is.EqualTo(HResult.Ok)); + Assert.That(provider.WritePlaceholder(@"tree\cached\hot.bin"), Is.EqualTo(HResult.Ok)); + Assert.That(provider.WritePlaceholder(@"tree\cached\cold.bin"), Is.EqualTo(HResult.Ok)); + Assert.That(provider.WritePlaceholder(@"tree\cached\deleted.bin"), Is.EqualTo(HResult.Ok)); + Assert.That(provider.WritePlaceholder(@"tree\cached\recall-control.bin"), Is.EqualTo(HResult.Ok)); + Assert.That(provider.WritePlaceholder(@"tree\cached\deleted-directory"), Is.EqualTo(HResult.Ok)); + if (!fullBackup) + { + Assert.That(provider.WritePlaceholder(@"tree\cached\notification-control.bin"), Is.EqualTo(HResult.Ok)); + } + Assert.That(File.ReadAllBytes(Path.Combine(root, @"tree\cached\hot.bin")), Is.EqualTo(Projection.Content)); + File.Delete(Path.Combine(root, @"tree\cached\deleted.bin")); + Directory.Delete(Path.Combine(root, @"tree\cached\deleted-directory")); + provider.Stop(); + if (fullBackup) + { + Directory.Move(root, Path.Combine(backup, "src")); + Directory.CreateDirectory(root); + } + else + { + Directory.Move(Path.Combine(root, "tree"), Path.Combine(backup, "tree")); + } + + provider.Start(); + + Assert.That(provider.WritePlaceholder(DehydrateBackupProcess.NotificationControl), Is.EqualTo(HResult.Ok)); + DehydrateBackupProcess.DeleteControl(enlistment); + Assert.That(SpinWait.SpinUntil( + () => provider.Notifications.Contains("Deleted:" + DehydrateBackupProcess.NotificationControl), + TimeSpan.FromSeconds(10)), Is.True, "The live provider did not observe the notification control."); + provider.Notifications.Clear(); + + if (!fullBackup) + { + DehydrateBackupProcess.DeleteMovedControl(enlistment, BackupName); + SpinWait.SpinUntil(() => !provider.Notifications.IsEmpty, TimeSpan.FromSeconds(1)); + TestContext.WriteLine("Moved-placeholder client-delete notifications observed: " + provider.Notifications.Count); + provider.Notifications.Clear(); + } + + if (!fullBackup) + { + string[] names = Directory.EnumerateDirectories(Path.Combine(backup, "tree")).Select(Path.GetFileName).ToArray(); + Assert.That(names, Does.Contain("cached")); + Assert.That(names, Does.Contain("virtual-only")); + IOException unavailable = Assert.Throws(() => + Directory.GetFileSystemEntries(Path.Combine(backup, @"tree\virtual-only"))); + Assert.That(unavailable.HResult & 0xFFFF, Is.EqualTo(369)); + } + + string control = Path.Combine(backup, fullBackup ? @"src\tree\cached\recall-control.bin" : @"tree\cached\recall-control.bin"); + int recallsBeforeControl = provider.DataCallbacks; + if (fullBackup) + { + IOException unavailable = Assert.Throws(() => File.ReadAllBytes(control)); + Assert.That(unavailable.HResult & 0xFFFF, Is.EqualTo(372)); + Assert.That(provider.DataCallbacks, Is.EqualTo(recallsBeforeControl)); + } + else + { + Assert.That(File.ReadAllBytes(control), Is.EqualTo(Projection.Content)); + Assert.That(provider.DataCallbacks, Is.EqualTo(recallsBeforeControl + 1)); + } + + Assert.That(File.ReadAllBytes(Path.Combine(root, "health.bin")), Is.EqualTo(Projection.Content)); + int recallsBeforeDelete = provider.DataCallbacks; + DehydrateBackupProcess.Summary result = DehydrateBackupProcess.Delete(enlistment, BackupName); + Assert.That(result.Failures, Is.Empty); + Assert.That(result.FilesDeleted, Is.EqualTo(4)); + Assert.That(result.DirectoriesDeleted, Is.EqualTo(fullBackup ? 5 : 4)); + + Assert.That(result.LiveReads, Is.GreaterThan(0)); + Assert.That(provider.DataCallbacks, Is.EqualTo(recallsBeforeDelete + result.LiveReads)); + Assert.That(Directory.Exists(backup), Is.False); + Assert.That(File.ReadAllBytes(Path.Combine(root, "health.bin")), Is.EqualTo(Projection.Content)); + provider.Stop(); + Assert.That(provider.Notifications, Is.Empty); + Assert.That(provider.Errors, Is.Empty); + } + finally + { + provider.Stop(); + } + } + } + + private sealed class Projection : IRequiredCallbacks, IDisposable + { + public static readonly byte[] Content = DehydrateBackupProcess.Content; + private readonly Dictionary paths = new Dictionary + { + { "tree", true }, + { @"tree\cached", true }, + { @"tree\cached\hot.bin", false }, + { @"tree\cached\cold.bin", false }, + { @"tree\cached\deleted.bin", false }, + { @"tree\cached\recall-control.bin", false }, + { @"tree\cached\deleted-directory", true }, + { @"tree\cached\notification-control.bin", false }, + { @"tree\virtual-only", true }, + { "health.bin", false }, + { DehydrateBackupProcess.NotificationControl, false }, + }; + private readonly ConcurrentDictionary enumerations = new ConcurrentDictionary(); + private readonly string root; + private VirtualizationInstance instance; + private int dataCallbacks; + private bool started; + + public Projection(string root) + { + this.root = root; + for (int index = 0; index < 16; ++index) + { + this.paths.Add($"health-{index}.bin", false); + } + + this.instance = this.CreateInstance(); + } + + public ConcurrentQueue Errors { get; } = new ConcurrentQueue(); + public ConcurrentQueue Notifications { get; } = new ConcurrentQueue(); + public int DataCallbacks => Volatile.Read(ref this.dataCallbacks); + + public void Start() + { + this.instance ??= this.CreateInstance(); + Assert.That(this.instance.StartVirtualizing(this), Is.EqualTo(HResult.Ok)); + this.started = true; + } + + public void Stop() + { + if (this.started) + { + this.instance.StopVirtualizing(); + this.instance = null; + this.started = false; + } + } + + public void Dispose() + { + this.Stop(); + this.instance?.Dispose(); + } + + private VirtualizationInstance CreateInstance() + { + VirtualizationInstance instance = new VirtualizationInstance( + this.root, poolThreadCount: 2, concurrentThreadCount: 2, enableNegativePathCache: false, + notificationMappings: new List + { + new NotificationMapping( + NotificationType.PreDelete | NotificationType.FileRenamed | NotificationType.FileHandleClosedFileDeleted, + string.Empty), + }); + instance.OnQueryFileName = path => this.paths.ContainsKey(path) ? HResult.Ok : HResult.FileNotFound; + instance.OnNotifyPreDelete = this.NotifyPreDelete; + instance.OnNotifyFileRenamed = this.NotifyRenamed; + instance.OnNotifyFileHandleClosedFileModifiedOrDeleted = this.NotifyClosed; + return instance; + } + + private bool NotifyPreDelete(string path, bool directory, uint processId, string image) + { + this.Notifications.Enqueue("PreDelete:" + path); + return true; + } + + private void NotifyRenamed(string path, string destination, bool directory, uint processId, string image, out NotificationType mask) + { + mask = NotificationType.UseExistingMask; + this.Notifications.Enqueue("Renamed:" + path + "->" + destination); + } + + private void NotifyClosed(string path, bool directory, bool modified, bool deleted, uint processId, string image) + { + if (deleted) + { + this.Notifications.Enqueue("Deleted:" + path); + } + } + + public HResult WritePlaceholder(string path) + { + DateTime now = DateTime.UtcNow; + bool directory = this.paths[path]; + HResult result = this.instance.WritePlaceholderInfo( + path, now, now, now, now, directory ? FileAttributes.Directory : FileAttributes.Archive, + directory ? 0 : Content.Length, directory, new byte[] { 1 }, new byte[] { 1 }); + if (result != HResult.Ok) + { + this.Errors.Enqueue($"WritePlaceholderInfo({path}): {result}"); + } + + return result; + } + + public HResult StartDirectoryEnumerationCallback( + int commandId, Guid enumerationId, string virtualPath, uint triggeringProcessId, string triggeringProcessImageFileName) + { + this.enumerations[enumerationId] = new EnumerationState(virtualPath == @"tree\cached\deleted-directory"); + return HResult.Ok; + } + + public HResult EndDirectoryEnumerationCallback(Guid enumerationId) + { + this.enumerations.TryRemove(enumerationId, out _); + return HResult.Ok; + } + + public HResult GetDirectoryEnumerationCallback( + int commandId, Guid enumerationId, string filterFileName, bool restartScan, IDirectoryEnumerationResults results) + { + // Inject projected names except in the empty directory used to create a tombstone. + EnumerationState enumeration = this.enumerations[enumerationId]; + string[] children = enumeration.Empty ? Array.Empty() : new[] { "cached", "virtual-only" }; + int index = restartScan ? 0 : enumeration.Index; + DateTime now = DateTime.UtcNow; + while (index < children.Length) + { + if (!results.Add(children[index], 0, true, FileAttributes.Directory, now, now, now, now)) + { + break; + } + + enumeration.Index = ++index; + } + + return HResult.Ok; + } + + private sealed class EnumerationState + { + public EnumerationState(bool empty) + { + this.Empty = empty; + } + + public bool Empty { get; } + public int Index { get; set; } + } + + public HResult GetPlaceholderInfoCallback( + int commandId, string virtualPath, uint triggeringProcessId, string triggeringProcessImageFileName) + { + return this.paths.ContainsKey(virtualPath) ? this.WritePlaceholder(virtualPath) : HResult.FileNotFound; + } + + public HResult GetFileDataCallback( + int commandId, string virtualPath, ulong byteOffset, uint length, Guid dataStreamId, + byte[] contentId, byte[] providerId, uint triggeringProcessId, string triggeringProcessImageFileName) + { + Interlocked.Increment(ref this.dataCallbacks); + using (IWriteBuffer buffer = this.instance.CreateWriteBuffer((uint)Content.Length)) + { + buffer.Stream.Write(Content, 0, Content.Length); + HResult result = this.instance.WriteFileData(dataStreamId, buffer, 0, (uint)Content.Length); + if (result != HResult.Ok) + { + this.Errors.Enqueue($"WriteFileData({virtualPath}): {result}"); + } + + return result; + } + } + } + } +} diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/DehydrateBackupProcess.cs b/GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/DehydrateBackupProcess.cs new file mode 100644 index 0000000000..8026e6ec90 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/DehydrateBackupProcess.cs @@ -0,0 +1,174 @@ +using GVFS.Common.Tracing; +using GVFS.FunctionalTests.Tools; +using GVFS.Platform.Windows; +using Microsoft.Win32.SafeHandles; +using NUnit.Framework; +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace GVFS.FunctionalTests.Tests.WhiteBox +{ + /// + /// Runs client-side test operations separately from the ProjFS provider process. + /// + internal static class DehydrateBackupProcess + { + public const string Switch = "--whitebox-dehydrate-backup"; + public const string NotificationControl = "notification-control.bin"; + public static readonly byte[] Content = Enumerable.Repeat((byte)'x', 4096).ToArray(); + private const int FileDispositionInfoEx = 21; + private const uint DispositionDelete = 1; + + public static int Run(string[] args) + { + if (args.Length != 4 || (args[1] != "control" && args[1] != "moved-control" && args[1] != "delete")) + { + throw new ArgumentException("Specify the white-box operation, fixture root, and backup name."); + } + + string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(args[2])); + string name = Path.GetFileName(root); + string prefix = nameof(DehydrateBackupDeletionTests); + if (!string.Equals(Path.GetDirectoryName(root), Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())), StringComparison.OrdinalIgnoreCase) || + !name.StartsWith(prefix, StringComparison.Ordinal) || + !Guid.TryParseExact(name.AsSpan(prefix.Length), "N", out _)) + { + throw new ArgumentException("The white-box helper requires a disposable dehydrate-backup test fixture."); + } + + if (args[1] == "control") + { + DeleteControlFile(Path.Combine(root, "src", NotificationControl)); + return 0; + } + + if (args[1] == "moved-control") + { + using (WindowsDehydrateBackup scope = WindowsDehydrateBackup.Open(NullTracer.Instance, root, args[3])) + { + DeleteControlFile(Path.Combine(root, "dehydrate_backup", args[3], "tree", "cached", NotificationControl)); + } + + return 0; + } + + int liveReads = 0; + using (WindowsDehydrateBackup scope = WindowsDehydrateBackup.Open(NullTracer.Instance, root, args[3])) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(progress: _ => + { + byte[] data = File.ReadAllBytes(Path.Combine(root, "src", $"health-{liveReads}.bin")); + if (!data.SequenceEqual(Content)) + { + throw new IOException("The live provider returned unexpected contents during backup deletion."); + } + + ++liveReads; + }); + Console.WriteLine(JsonSerializer.Serialize(new Summary + { + FilesDeleted = result.FilesDeleted, + DirectoriesDeleted = result.DirectoriesDeleted, + LiveReads = liveReads, + Failures = result.FailureSamples.Select(failure => failure.Path + ": " + failure.Exception).ToArray(), + })); + return result.Succeeded ? 0 : 1; + } + } + + public static Summary Delete(string root, string backupName) + { + ProcessResult result = Start("delete", root, backupName); + return JsonSerializer.Deserialize(result.Output) + ?? throw new IOException("The backup test process returned no deletion summary."); + } + + public static void DeleteControl(string root) + { + Start("control", root, string.Empty); + } + + public static void DeleteMovedControl(string root, string backupName) + { + Start("moved-control", root, backupName); + } + + private static void DeleteControlFile(string path) + { + // Follow normal client opens, then verify the pinned target before arming deletion. + using (SafeFileHandle handle = CreateFileW( + @"\\?\" + path, + (uint)(GVFS.Common.NativeMethods.FileAccess.DELETE | GVFS.Common.NativeMethods.FileAccess.FILE_READ_ATTRIBUTES), + FileShare.Read, IntPtr.Zero, FileMode.Open, 0, IntPtr.Zero)) + { + if (handle.IsInvalid) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to open the notification control."); + } + + StringBuilder finalPath = new StringBuilder(32768); + uint length = GetFinalPathNameByHandleW(handle, finalPath, (uint)finalPath.Capacity, 0); + if (length == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to resolve the notification control."); + } + + if (length >= finalPath.Capacity || + !string.Equals(finalPath.ToString(), @"\\?\" + path, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("The notification control resolves outside the disposable fixture path."); + } + + uint disposition = DispositionDelete; + if (!SetFileInformationByHandle(handle, FileDispositionInfoEx, ref disposition, sizeof(uint))) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to delete the notification control."); + } + } + } + + private static ProcessResult Start(string operation, string root, string backupName) + { + ProcessStartInfo info = new ProcessStartInfo + { + FileName = Path.Combine(TestContext.CurrentContext.TestDirectory, "GVFS.FunctionalTests.exe"), + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + info.ArgumentList.Add(Switch); + info.ArgumentList.Add(operation); + info.ArgumentList.Add(root); + info.ArgumentList.Add(backupName); + ProcessResult result = ProcessHelper.Run(info, timeoutMs: 30_000); + Assert.That(result.ExitCode, Is.Zero, result.Errors + Environment.NewLine + result.Output); + return result; + } + + internal sealed class Summary + { + public long FilesDeleted { get; set; } + public long DirectoriesDeleted { get; set; } + public int LiveReads { get; set; } + public string[] Failures { get; set; } + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string path, uint access, FileShare share, IntPtr security, FileMode mode, uint flags, IntPtr template); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle, StringBuilder path, uint size, uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle(SafeFileHandle handle, int informationClass, ref uint flags, uint size); + } +} diff --git a/GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/WindowsDehydrateBackupTests.cs b/GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/WindowsDehydrateBackupTests.cs new file mode 100644 index 0000000000..400023f157 --- /dev/null +++ b/GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/WindowsDehydrateBackupTests.cs @@ -0,0 +1,632 @@ +using GVFS.Common.Tracing; +using GVFS.Platform.Windows; +using Microsoft.Win32.SafeHandles; +using Moq; +using NUnit.Framework; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; + +namespace GVFS.FunctionalTests.Tests.WhiteBox +{ + /// + /// White-box tests for backup scope and deletion through real Windows filesystem handles. + /// + [TestFixture] + [NUnit.Framework.Category(Categories.WhiteBox)] + public class WindowsDehydrateBackupTests + { + private const string BackupName = "20261030_101814"; + private string enlistment; + private string container; + private string backup; + private Mock tracer; + + [SetUp] + public void SetUp() + { + this.enlistment = Path.Combine(Path.GetTempPath(), nameof(WindowsDehydrateBackupTests) + Guid.NewGuid().ToString("N")); + this.container = Path.Combine(this.enlistment, "dehydrate_backup"); + this.backup = Path.Combine(this.container, BackupName); + Directory.CreateDirectory(this.backup); + this.tracer = new Mock(); + } + + [TearDown] + public void TearDown() + { + foreach (string file in Directory.EnumerateFiles(this.enlistment, "*", SearchOption.AllDirectories)) + { + File.SetAttributes(file, FileAttributes.Normal); + } + + Directory.Delete(this.enlistment, recursive: true); + } + + [TestCase] + public void DeletesOnlyTheSelectedBackupIncludingReadonlyAndLongUnicodePaths() + { + string nested = this.backup; + for (int index = 0; index < 6; ++index) + { + nested = Path.Combine(nested, new string('a', 45)); + } + + Directory.CreateDirectory(nested); + string file = Path.Combine(nested, "name-\u00e9.txt"); + File.WriteAllText(file, "Backup contents"); + File.SetAttributes(file, FileAttributes.ReadOnly); + Directory.CreateDirectory(Path.Combine(this.backup, "empty")); + string otherBackup = Path.Combine(this.container, "20261030_111814"); + Directory.CreateDirectory(otherBackup); + File.WriteAllText(Path.Combine(otherBackup, "keep.txt"), "Other backup"); + string utility = Path.Combine(this.container, "cleanup.exe"); + File.WriteAllText(utility, "Keep the utility"); + string workingDirectory = Path.Combine(this.enlistment, "src"); + Directory.CreateDirectory(workingDirectory); + File.WriteAllText(Path.Combine(workingDirectory, "keep.txt"), "Working file"); + + using (WindowsDehydrateBackup scope = this.Open()) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.True, this.Failures(result)); + Assert.That(result.FilesDeleted, Is.EqualTo(1)); + Assert.That(result.DirectoriesDeleted, Is.EqualTo(8)); + Assert.That(Directory.Exists(this.backup), Is.False); + } + + Assert.That(File.ReadAllText(utility), Is.EqualTo("Keep the utility")); + Assert.That(File.ReadAllText(Path.Combine(otherBackup, "keep.txt")), Is.EqualTo("Other backup")); + Assert.That(File.ReadAllText(Path.Combine(workingDirectory, "keep.txt")), Is.EqualTo("Working file")); + } + + [TestCase] + public void RetainsBusyEntriesAndRetriesWithoutDeletingTheContainer() + { + string busy = Path.Combine(this.backup, "busy.txt"); + File.WriteAllText(busy, "Busy file"); + File.WriteAllText(Path.Combine(this.backup, "other.txt"), "Other file"); + using (WindowsDehydrateBackup scope = this.Open()) + { + using (new FileStream(busy, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + WindowsDehydrateBackup.DeletionResult first = scope.Delete(); + Assert.That(first.Succeeded, Is.False); + Assert.That(first.FilesDeleted, Is.EqualTo(1)); + Assert.That(first.FailureCount, Is.EqualTo(1)); + Assert.That(first.FailureSamples.Single().Path, Is.EqualTo(busy)); + Assert.That(((Win32Exception)first.FailureSamples.Single().Exception).NativeErrorCode, Is.EqualTo(32)); + Assert.That(File.ReadAllText(busy), Is.EqualTo("Busy file")); + } + + WindowsDehydrateBackup.DeletionResult retry = scope.Delete(); + Assert.That(retry.Succeeded, Is.True, this.Failures(retry)); + Assert.That(retry.FilesDeleted, Is.EqualTo(1)); + Assert.That(scope.Delete().Succeeded, Is.True); + } + + Assert.That(Directory.Exists(this.container), Is.True); + this.tracer.Verify( + tracer => tracer.RelatedWarning(It.IsAny(), It.IsAny()), + Times.AtLeastOnce); + } + + [TestCase(true)] + [TestCase(false)] + public void DeletesNamesWithUnpairedUtf16CodeUnits(bool highSurrogate) + { + string name = highSurrogate ? "name-\uD800.txt" : "name-\uDC00.txt"; + File.WriteAllText(Path.Combine(this.backup, name), "Backup"); + using (WindowsDehydrateBackup scope = this.Open()) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.True, this.Failures(result)); + Assert.That(result.FilesDeleted, Is.EqualTo(1)); + Assert.That(result.EntriesAlreadyAbsent, Is.Zero); + } + } + + [TestCase] + public void PreservesRawUtf16FailureResultsWithJsonTracing() + { + string file = Path.Combine(this.backup, "busy-\uD800.txt"); + string log = Path.Combine(this.enlistment, "events.log"); + File.WriteAllText(file, "Backup"); + using (JsonTracer tracer = new JsonTracer("DehydrateBackupTests", "RetainedEntry", disableTelemetry: true)) + { + tracer.AddLogFileEventListener(log, EventLevel.Verbose, Keywords.Any); + using (WindowsDehydrateBackup scope = WindowsDehydrateBackup.Open(tracer, this.enlistment, BackupName)) + { + using (new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.False); + Assert.That(result.FailureCount, Is.EqualTo(1)); + Assert.That(result.FailureSamples.Single().Path, Is.EqualTo(file)); + } + + Assert.That(scope.Delete().Succeeded, Is.True); + } + } + + Assert.That(File.ReadAllText(log), Does.Contain("Dehydrate backup cleanup retained an entry.")); + } + + [TestCase] + public void BoundsFailureSamplesWithoutCappingTheFailureCount() + { + const int BusyFileCount = 17; + List readers = new List(); + using (WindowsDehydrateBackup scope = this.Open()) + { + try + { + for (int index = 0; index < BusyFileCount; ++index) + { + string file = Path.Combine(this.backup, $"busy-{index}.txt"); + File.WriteAllText(file, "Backup"); + readers.Add(new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read)); + } + + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.False); + Assert.That(result.FailureCount, Is.EqualTo(BusyFileCount)); + Assert.That(result.FailureSamples.Count, Is.EqualTo(16)); + Assert.That(result.FilesDeleted, Is.Zero); + foreach (FileStream reader in readers) + { + Assert.That(File.Exists(reader.Name), Is.True); + } + } + finally + { + foreach (FileStream reader in readers) + { + reader.Dispose(); + } + } + + WindowsDehydrateBackup.DeletionResult retry = scope.Delete(); + Assert.That(retry.Succeeded, Is.True, this.Failures(retry)); + Assert.That(retry.FilesDeleted, Is.EqualTo(BusyFileCount)); + } + } + + [TestCase(false)] + [TestCase(true)] + public void CompatibleReadersDoNotLeaveDeletedEntriesInTheNamespace(bool directoryReader) + { + string file = Path.Combine(this.backup, "contents.txt"); + File.WriteAllText(file, "Backup"); + using (SafeFileHandle reader = CreateFileW( + directoryReader ? this.backup : file, + (uint)GVFS.Common.NativeMethods.FileAccess.FILE_READ_DATA, + FileShare.ReadWrite | FileShare.Delete, + IntPtr.Zero, + FileMode.Open, + (uint)GVFS.Common.NativeMethods.FileAttributes.FILE_FLAG_BACKUP_SEMANTICS, + IntPtr.Zero)) + { + Assert.That(reader.IsInvalid, Is.False, $"Open reader failed: {Marshal.GetLastWin32Error()}"); + using (WindowsDehydrateBackup scope = this.Open()) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.True, this.Failures(result)); + Assert.That(Directory.Exists(this.backup), Is.False); + } + } + } + + [TestCase(false)] + [TestCase(true)] + public void SupportsCurrentTimestampNamesAndWorktreeBackupLayout(bool worktree) + { + const string CurrentName = "20261030_101814_1234567"; + string backupParentRoot = worktree ? Path.Combine(this.enlistment, "worktrees") : this.enlistment; + string liveRoot = Path.Combine(backupParentRoot, worktree ? "feature" : "src"); + Directory.CreateDirectory(liveRoot); + string sentinel = Path.Combine(liveRoot, "keep.txt"); + File.WriteAllText(sentinel, "Live worktree"); + string parent = worktree + ? Path.Combine(backupParentRoot, "dehydrate_backup", Path.GetFileName(liveRoot)) + : this.container; + Directory.CreateDirectory(parent); + string selected = Path.Combine(parent, CurrentName); + Directory.Move(this.backup, selected); + File.WriteAllText(Path.Combine(selected, "contents.txt"), "Backup"); + using (WindowsDehydrateBackup scope = WindowsDehydrateBackup.Open( + this.tracer.Object, backupParentRoot, CurrentName, worktreeName: worktree ? Path.GetFileName(liveRoot) : null)) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.True, this.Failures(result)); + } + + Assert.That(Directory.Exists(selected), Is.False); + Assert.That(Directory.Exists(parent), Is.True); + Assert.That(Directory.Exists(this.container), Is.True); + Assert.That(File.ReadAllText(sentinel), Is.EqualTo("Live worktree")); + } + + [TestCase("relative")] + [TestCase(@"\relative")] + [TestCase(@"C:\")] + [TestCase(@"\\server\share\enlistment")] + public void RejectsUnsupportedBackupParentPaths(string backupParentRoot) + { + Assert.Throws(() => WindowsDehydrateBackup.Open( + this.tracer.Object, backupParentRoot, BackupName)); + Assert.That(Directory.Exists(this.backup), Is.True); + } + + [TestCase(@"..\src")] + [TestCase(@"feature\child")] + [TestCase("feature.")] + public void RejectsWorktreeNamesThatCanEscapeTheContainer(string worktreeName) + { + Assert.Throws(() => WindowsDehydrateBackup.Open( + this.tracer.Object, this.enlistment, BackupName, worktreeName: worktreeName)); + Assert.That(Directory.Exists(this.backup), Is.True); + } + + [TestCase] + public void CancellationPreservesProgressAndAllowsRetry() + { + File.WriteAllText(Path.Combine(this.backup, "first.txt"), "First"); + File.WriteAllText(Path.Combine(this.backup, "second.txt"), "Second"); + using (WindowsDehydrateBackup scope = this.Open()) + using (CancellationTokenSource cancellation = new CancellationTokenSource()) + { + WindowsDehydrateBackup.DeletionResult beforeStart = scope.Delete(new CancellationToken(canceled: true)); + Assert.That(beforeStart.Cancelled, Is.True); + Assert.That(beforeStart.FilesDeleted, Is.Zero); + WindowsDehydrateBackup.DeletionResult partial = scope.Delete(cancellation.Token, progress => cancellation.Cancel()); + Assert.That(partial.Cancelled, Is.True); + Assert.That(partial.FilesDeleted, Is.EqualTo(1)); + Assert.That(partial.BackupDeleted, Is.False); + Assert.That(partial.FailureCount, Is.Zero); + Assert.That(scope.Delete().Succeeded, Is.True); + } + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(2)] + public void ProgressErrorsStopDeletionAndAllowRetry(int errorKind) + { + string child = Path.Combine(this.backup, "child"); + Directory.CreateDirectory(child); + File.WriteAllText(Path.Combine(child, "a.txt"), "First"); + string remaining = Path.Combine(child, "z.txt"); + File.WriteAllText(remaining, "Second"); + Exception error = errorKind == 0 ? new IOException("Progress failed") : + errorKind == 1 ? new Win32Exception(2, "Progress failed") : + new InvalidOperationException("Progress failed"); + + using (WindowsDehydrateBackup scope = this.Open()) + { + Exception actual = Assert.Catch(() => scope.Delete(progress: _ => throw error)); + Assert.That(actual, Is.SameAs(error)); + Assert.That(File.ReadAllText(remaining), Is.EqualTo("Second")); + this.tracer.Verify( + tracer => tracer.RelatedWarning(It.IsAny(), It.IsAny()), + Times.Never); + WindowsDehydrateBackup.DeletionResult retry = scope.Delete(); + Assert.That(retry.Succeeded, Is.True, this.Failures(retry)); + Assert.That(retry.FilesDeleted, Is.EqualTo(1)); + } + } + + [TestCase] + public void CompletionCallbackErrorsDoNotInvalidateTheDeletedScope() + { + IOException error = new IOException("Progress failed"); + using (WindowsDehydrateBackup scope = this.Open()) + { + Exception actual = Assert.Catch(() => scope.Delete(progress: _ => + { + Assert.That(Directory.Exists(this.backup), Is.False); + throw error; + })); + Assert.That(actual, Is.SameAs(error)); + WindowsDehydrateBackup.DeletionResult retry = scope.Delete(); + Assert.That(retry.Succeeded, Is.True, this.Failures(retry)); + Assert.That(retry.DirectoriesDeleted, Is.Zero); + } + } + + [TestCase(false)] + [TestCase(true)] + public void RejectsReentrantOperationsFromProgressCallbacks(bool dispose) + { + File.WriteAllText(Path.Combine(this.backup, "contents.txt"), "Backup"); + using (WindowsDehydrateBackup scope = this.Open()) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(progress: _ => + { + if (dispose) + { + Assert.Throws(() => scope.Dispose()); + } + else + { + Assert.Throws(() => scope.Delete()); + } + }); + Assert.That(result.Succeeded, Is.True, this.Failures(result)); + } + } + + [TestCase(255, true)] + [TestCase(256, false)] + public void ReportsUnsupportedDepthWithoutDeletingDeeperEntries(int depth, bool supported) + { + string nested = this.backup; + for (int index = 0; index < depth; ++index) + { + nested = Path.Combine(nested, "d"); + } + + Directory.CreateDirectory(nested); + string file = Path.Combine(nested, "keep.txt"); + File.WriteAllText(file, "Backup"); + using (WindowsDehydrateBackup scope = this.Open()) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.EqualTo(supported), this.Failures(result)); + if (!supported) + { + Assert.That(result.FailureCount, Is.EqualTo(1)); + Assert.That(result.FailureSamples.Single().Path, Is.EqualTo(nested)); + Assert.That(File.ReadAllText(file), Is.EqualTo("Backup")); + } + } + } + + [TestCase("src")] + [TestCase("..")] + [TestCase(@"..\src")] + [TestCase(@"20261030_101814\child")] + [TestCase(@"20261030_101814:stream")] + [TestCase("20260230_101814")] + [TestCase("20261030_101814 ")] + public void RejectsNamesOutsideTheTimestampedBackupContract(string name) + { + Assert.Throws(() => WindowsDehydrateBackup.Open(this.tracer.Object, this.enlistment, name)); + Assert.That(Directory.Exists(this.backup), Is.True); + } + + [TestCase] + public void RejectsAReplacementBackupWithTheSameName() + { + string identity; + using (WindowsDehydrateBackup original = this.Open()) + { + identity = original.Identity; + } + + Directory.Move(this.backup, Path.Combine(this.container, "saved")); + Directory.CreateDirectory(this.backup); + string replacement = Path.Combine(this.backup, "replacement.txt"); + File.WriteAllText(replacement, "Not authorized"); + Assert.Throws(() => WindowsDehydrateBackup.Open( + this.tracer.Object, this.enlistment, BackupName, identity)); + Assert.That(File.ReadAllText(replacement), Is.EqualTo("Not authorized")); + } + + [TestCase] + public void DeletesABackupWithTheExpectedIdentity() + { + string identity; + using (WindowsDehydrateBackup original = this.Open()) + { + identity = original.Identity; + } + + using (WindowsDehydrateBackup scope = WindowsDehydrateBackup.Open( + this.tracer.Object, this.enlistment, BackupName, expectedIdentity: identity)) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.True, this.Failures(result)); + } + } + + [TestCase] + public void DoesNotFollowAChildReplacedAfterEnumeration() + { + File.WriteAllText(Path.Combine(this.backup, "a.txt"), "First"); + string child = Path.Combine(this.backup, "z-child"); + Directory.CreateDirectory(child); + File.WriteAllText(Path.Combine(child, "old.txt"), "Original"); + bool replaced = false; + using (WindowsDehydrateBackup scope = this.Open()) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(progress: progress => + { + if (!replaced && progress.FilesDeleted == 1) + { + Directory.Move(child, Path.Combine(this.container, "saved-child")); + Directory.CreateDirectory(child); + File.WriteAllText(Path.Combine(child, "replacement.txt"), "Not authorized"); + replaced = true; + } + }); + Assert.That(replaced, Is.True); + Assert.That(result.Succeeded, Is.False); + Assert.That(result.FailureCount, Is.EqualTo(1)); + Assert.That(result.FailureSamples.Single().Path, Is.EqualTo(child)); + Assert.That(File.ReadAllText(Path.Combine(child, "replacement.txt")), Is.EqualTo("Not authorized")); + } + } + + [TestCase] + public void EnumeratesMultipleNativeBuffersWithoutSkippingDeletedEntries() + { + const int FileCount = 2500; + for (int index = 0; index < FileCount; ++index) + { + File.WriteAllText(Path.Combine(this.backup, $"file-{index:D4}.txt"), "Backup"); + } + + using (WindowsDehydrateBackup scope = this.Open()) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.True, this.Failures(result)); + Assert.That(result.FilesDeleted, Is.EqualTo(FileCount)); + Assert.That(Directory.Exists(this.backup), Is.False); + } + } + + [TestCase] + public void KeepsExclusiveBackupOwnershipUntilTheScopeIsDisposed() + { + using (WindowsDehydrateBackup scope = this.Open()) + { + Win32Exception busy = Assert.Throws(() => this.Open()); + Assert.That(busy.NativeErrorCode, Is.EqualTo(32)); + Assert.Throws(() => Directory.Move(this.backup, Path.Combine(this.container, "renamed"))); + } + + using (WindowsDehydrateBackup retry = this.Open()) + { + Assert.That(retry.Delete().Succeeded, Is.True); + } + } + + [TestCase] + public void CountsAnEntryRemovedByAnotherProcessWithoutFollowingNewPaths() + { + File.WriteAllText(Path.Combine(this.backup, "a.txt"), "First"); + string removed = Path.Combine(this.backup, "z.txt"); + File.WriteAllText(removed, "Second"); + bool removedExternally = false; + using (WindowsDehydrateBackup scope = this.Open()) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(progress: progress => + { + if (!removedExternally && progress.FilesDeleted == 1) + { + File.Delete(removed); + removedExternally = true; + } + }); + Assert.That(result.Succeeded, Is.True, this.Failures(result)); + Assert.That(result.FilesDeleted, Is.EqualTo(1)); + Assert.That(result.EntriesAlreadyAbsent, Is.EqualTo(1)); + } + } + + [TestCase] + public void RejectsAnEnlistmentAliasThroughAnAncestorSymlink() + { + string alias = Path.Combine(this.enlistment, "alias"); + this.CreateDirectoryLink(alias, this.enlistment); + try + { + Assert.Throws(() => WindowsDehydrateBackup.Open(this.tracer.Object, alias, BackupName)); + Assert.That(Directory.Exists(this.backup), Is.True); + } + finally + { + Directory.Delete(alias); + } + } + + [TestCase] + public void RejectsDirectorySymlinksWithoutTouchingTheTarget() + { + string external = Path.Combine(this.enlistment, "outside"); + Directory.CreateDirectory(external); + string sentinel = Path.Combine(external, "keep.txt"); + File.WriteAllText(sentinel, "Do not touch"); + string link = Path.Combine(this.backup, "link"); + this.CreateDirectoryLink(link, external); + try + { + using (WindowsDehydrateBackup scope = this.Open()) + { + WindowsDehydrateBackup.DeletionResult result = scope.Delete(); + Assert.That(result.Succeeded, Is.False); + Assert.That(result.FailureCount, Is.EqualTo(1)); + Assert.That(result.FailureSamples.Single().Path, Is.EqualTo(link)); + } + + Assert.That(File.ReadAllText(sentinel), Is.EqualTo("Do not touch")); + } + finally + { + Directory.Delete(link); + } + } + + [TestCase] + public void RejectsAContainerSymlink() + { + string external = Path.Combine(this.enlistment, "outside"); + Directory.Move(this.container, external); + this.CreateDirectoryLink(this.container, external); + try + { + Assert.Throws(() => this.Open()); + Assert.That(Directory.Exists(Path.Combine(external, BackupName)), Is.True); + } + finally + { + Directory.Delete(this.container); + } + } + + [TestCase(false)] + [TestCase(true)] + public void RejectsReparsePointsAtTheBackupAndWorktreeScope(bool worktree) + { + string external = Path.Combine(this.enlistment, "outside"); + Directory.Move(this.backup, external); + string target = worktree ? Path.Combine(external, BackupName) : external; + Directory.CreateDirectory(target); + string sentinel = Path.Combine(target, "keep.txt"); + File.WriteAllText(sentinel, "Not authorized"); + string link = worktree ? Path.Combine(this.container, "feature") : this.backup; + this.CreateDirectoryLink(link, external); + try + { + Assert.Throws(() => WindowsDehydrateBackup.Open( + this.tracer.Object, this.enlistment, BackupName, worktreeName: worktree ? "feature" : null)); + Assert.That(File.ReadAllText(sentinel), Is.EqualTo("Not authorized")); + } + finally + { + Directory.Delete(link); + } + } + + private WindowsDehydrateBackup Open() => + WindowsDehydrateBackup.Open(this.tracer.Object, this.enlistment, BackupName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string path, uint access, FileShare share, IntPtr security, FileMode mode, uint flags, IntPtr template); + + private string Failures(WindowsDehydrateBackup.DeletionResult result) => + string.Join(Environment.NewLine, result.FailureSamples.Select(failure => failure.Exception.ToString())); + + private void CreateDirectoryLink(string path, string target) + { + try + { + Directory.CreateSymbolicLink(path, target); + } + catch (UnauthorizedAccessException) + { + Assert.Ignore("The test requires permission to create directory symbolic links."); + } + catch (IOException exception) when ((exception.HResult & 0xFFFF) == 1314) + { + Assert.Ignore("The test requires permission to create directory symbolic links."); + } + } + } +} diff --git a/GVFS/GVFS.Platform.Windows/WindowsDehydrateBackup.Native.cs b/GVFS/GVFS.Platform.Windows/WindowsDehydrateBackup.Native.cs new file mode 100644 index 0000000000..efe2592161 --- /dev/null +++ b/GVFS/GVFS.Platform.Windows/WindowsDehydrateBackup.Native.cs @@ -0,0 +1,356 @@ +#nullable enable + +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace GVFS.Platform.Windows +{ + public sealed partial class WindowsDehydrateBackup + { + private static class Native + { + private const uint ProjFsTag = 0x9000001C; + private const uint TombstoneTag = 0xA0000022; + private const uint DirectoryAttribute = 0x10; + private const uint ReparseAttribute = 0x400; + private const uint ReadAttributes = 0x80; + private const uint DeleteAccess = 0x10000; + private const uint SynchronizeAccess = 0x100000; + private const uint OpenReparsePoint = 0x200000; + private const uint BackupSemantics = 0x2000000; + private const uint OpenNoRecall = 0x100000; + private const uint ReturnOnDiskEntriesOnly = 8; + private const uint RestartScan = 1; + private const uint OpenExisting = 1; + private const uint DirectoryFile = 1; + private const uint NonDirectoryFile = 0x40; + private const uint SynchronousIoNonAlert = 0x20; + private const uint OpenForBackupIntent = 0x4000; + private const uint DispositionDelete = 1; + private const uint DispositionPosixSemantics = 2; + private const uint DispositionIgnoreReadonly = 0x10; + private const int FileAttributeTagInfo = 9; + private const int FileIdInfo = 18; + private const int FileDispositionInfoEx = 21; + private const int FileIdExtdDirectoryInformation = 60; + private const int DirectoryEntryHeaderSize = 88; + private const int BufferSize = 64 * 1024; + private const int StatusNoMoreFiles = unchecked((int)0x80000006); + + public static SafeFileHandle OpenContainer(string path) + { + SafeFileHandle handle = CreateFileW( + @"\\?\" + path, + ReadAttributes | SynchronizeAccess, + FileShare.Read, + IntPtr.Zero, + FileMode.Open, + BackupSemantics | OpenReparsePoint | OpenNoRecall, + IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error, "Failed to open the backup container."); + } + + return handle; + } + + public static SafeFileHandle OpenChild(SafeFileHandle parent, string name, bool directory, bool forDeletion = true) + { + if (string.IsNullOrEmpty(name) || name == "." || name == ".." || + name.IndexOfAny(new[] { '\\', '/', ':', '\0' }) >= 0) + { + throw new IOException("The directory query returned an invalid child name."); + } + + IntPtr nameBuffer = Marshal.StringToHGlobalUni(name); + IntPtr unicodeBuffer = IntPtr.Zero; + bool parentReferenced = false; + try + { + unicodeBuffer = Marshal.AllocHGlobal(Marshal.SizeOf()); + UnicodeString unicode = new UnicodeString + { + Length = checked((ushort)(name.Length * sizeof(char))), + MaximumLength = checked((ushort)((name.Length + 1) * sizeof(char))), + Buffer = nameBuffer, + }; + Marshal.StructureToPtr(unicode, unicodeBuffer, false); + parent.DangerousAddRef(ref parentReferenced); + ObjectAttributes attributes = new ObjectAttributes + { + Length = Marshal.SizeOf(), + RootDirectory = parent.DangerousGetHandle(), + ObjectName = unicodeBuffer, + }; + // Handle-relative opens cannot be redirected by replacement of a path ancestor. + // Deny write/delete sharing while each object is inspected and deleted. + uint options = SynchronousIoNonAlert | OpenForBackupIntent | OpenReparsePoint; + options |= directory ? DirectoryFile : NonDirectoryFile; + int status = NtCreateFile( + out SafeFileHandle handle, + ReadAttributes | (forDeletion ? DeleteAccess : 0U) | SynchronizeAccess | (directory ? 1U : 0U), + ref attributes, + out _, + IntPtr.Zero, + 0, + FileShare.Read, + OpenExisting, + options, + IntPtr.Zero, + 0); + if (status < 0) + { + handle?.Dispose(); + throw StatusException(status, $"Failed to open a physical backup entry (NTSTATUS 0x{status:X8})."); + } + + return handle; + } + finally + { + if (parentReferenced) + { + parent.DangerousRelease(); + } + + Marshal.FreeHGlobal(unicodeBuffer); + Marshal.FreeHGlobal(nameBuffer); + } + } + + public static void VerifyOrdinaryDirectory(SafeFileHandle handle) + { + AttributeTag info = GetAttributes(handle); + if ((info.Attributes & DirectoryAttribute) == 0 || + (info.Attributes & ReparseAttribute) != 0) + { + throw new IOException("The backup scope must be an ordinary directory, not a reparse point."); + } + } + + public static void VerifySupportedReparsePoint(SafeFileHandle handle) + { + AttributeTag info = GetAttributes(handle); + if ((info.Attributes & ReparseAttribute) != 0 && info.Tag != ProjFsTag && info.Tag != TombstoneTag) + { + throw new IOException($"Backup cleanup refuses reparse tag 0x{info.Tag:X8}."); + } + } + + public static bool IsTombstone(SafeFileHandle handle) => GetAttributes(handle).Tag == TombstoneTag; + + public static FileIdentity GetIdentity(SafeFileHandle handle) + { + if (!GetFileInformationByHandleEx(handle, FileIdInfo, out FileIdentity identity, (uint)Marshal.SizeOf())) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to read the backup entry identity."); + } + + return identity; + } + + public static void VerifyFinalPath(SafeFileHandle handle, string expectedPath) + { + StringBuilder buffer = new StringBuilder(32768); + uint length = GetFinalPathNameByHandleW(handle, buffer, (uint)buffer.Capacity, 0); + if (length == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to resolve the backup container."); + } + + if (length >= buffer.Capacity || + !string.Equals(buffer.ToString(), @"\\?\" + expectedPath, StringComparison.OrdinalIgnoreCase)) + { + throw new IOException("The backup container resolves through a different filesystem path."); + } + } + + public static IEnumerable EnumeratePhysicalEntries(SafeFileHandle directory) + { + byte[] buffer = new byte[BufferSize]; + // OPEN_REPARSE_POINT alone still allows ProjFS to return projected-only names. + uint queryFlags = ReturnOnDiskEntriesOnly | RestartScan; + while (true) + { + int status = NtQueryDirectoryFileEx( + directory, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, out IoStatusBlock ioStatus, + buffer, (uint)buffer.Length, FileIdExtdDirectoryInformation, queryFlags, IntPtr.Zero); + queryFlags = ReturnOnDiskEntriesOnly; + if (status == StatusNoMoreFiles) + { + yield break; + } + + if (status < 0) + { + throw StatusException(status, "Failed to enumerate physical backup entries."); + } + + int length = checked((int)ioStatus.Information.ToUInt64()); + int offset = 0; + while (true) + { + Entry entry = ReadEntry(buffer, offset, length, out int nextOffset); + if (entry.Name != "." && entry.Name != "..") + { + yield return entry; + } + + if (nextOffset == 0) + { + break; + } + + offset += nextOffset; + } + } + } + + public static void MarkForDeletion(SafeFileHandle handle) + { + // Remove the name when our handle closes, even if a delete-sharing reader remains open. + // Do not change attributes through a path or recall file data to clear read-only state. + uint flags = DispositionDelete | DispositionPosixSemantics | DispositionIgnoreReadonly; + if (!SetFileInformationByHandle(handle, FileDispositionInfoEx, ref flags, sizeof(uint))) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to delete a physical backup entry."); + } + } + + private static AttributeTag GetAttributes(SafeFileHandle handle) + { + if (!GetFileInformationByHandleEx(handle, FileAttributeTagInfo, out AttributeTag attributes, (uint)Marshal.SizeOf())) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to read backup entry attributes."); + } + + return attributes; + } + + private static Entry ReadEntry(byte[] buffer, int offset, int length, out int nextOffset) + { + if (length > buffer.Length || offset < 0 || offset > length - DirectoryEntryHeaderSize) + { + throw new IOException("The physical directory query returned an invalid record."); + } + + nextOffset = BitConverter.ToInt32(buffer, offset); + int nameLength = BitConverter.ToInt32(buffer, offset + 60); + if (nameLength < 0 || (nameLength % sizeof(char)) != 0 || + nameLength > length - offset - DirectoryEntryHeaderSize || + (nextOffset != 0 && (nextOffset < DirectoryEntryHeaderSize + nameLength || nextOffset > length - offset))) + { + throw new IOException("The physical directory query returned an invalid entry length."); + } + + // NTFS names preserve raw UTF-16 code units, including unpaired surrogates. + return new Entry( + new string(MemoryMarshal.Cast(buffer.AsSpan(offset + DirectoryEntryHeaderSize, nameLength))), + (BitConverter.ToUInt32(buffer, offset + 56) & DirectoryAttribute) != 0, + new Guid(buffer.AsSpan(offset + 72, 16))); + } + + private static Win32Exception StatusException(int status, string message) => + new Win32Exception(unchecked((int)RtlNtStatusToDosError(status)), message); + + public readonly struct Entry + { + public Entry(string name, bool isDirectory, Guid fileId) + { + this.Name = name; + this.IsDirectory = isDirectory; + this.FileId = fileId; + } + + public string Name { get; } + public bool IsDirectory { get; } + public Guid FileId { get; } + } + + [StructLayout(LayoutKind.Sequential)] + public struct FileIdentity + { + public ulong Volume; + public Guid FileId; + + public override string ToString() => $"{this.Volume:X16}:{this.FileId:N}"; + } + + [StructLayout(LayoutKind.Sequential)] + private struct AttributeTag + { + public uint Attributes; + public uint Tag; + } + + [StructLayout(LayoutKind.Sequential)] + private struct UnicodeString + { + public ushort Length; + public ushort MaximumLength; + public IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ObjectAttributes + { + public int Length; + public IntPtr RootDirectory; + public IntPtr ObjectName; + public uint Attributes; + public IntPtr SecurityDescriptor; + public IntPtr SecurityQualityOfService; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoStatusBlock + { + public IntPtr Status; + public UIntPtr Information; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string path, uint access, FileShare share, IntPtr security, FileMode mode, uint flags, IntPtr template); + + [DllImport("ntdll.dll")] + private static extern int NtCreateFile( + out SafeFileHandle handle, uint access, ref ObjectAttributes attributes, out IoStatusBlock ioStatus, + IntPtr allocationSize, uint fileAttributes, FileShare share, uint disposition, uint options, + IntPtr extendedAttributes, uint extendedAttributesLength); + + [DllImport("ntdll.dll")] + private static extern int NtQueryDirectoryFileEx( + SafeFileHandle handle, IntPtr eventHandle, IntPtr apcRoutine, IntPtr apcContext, + out IoStatusBlock ioStatus, [Out] byte[] buffer, uint length, int informationClass, uint flags, IntPtr pattern); + + [DllImport("ntdll.dll")] + private static extern uint RtlNtStatusToDosError(int status); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandleEx( + SafeFileHandle handle, int informationClass, out FileIdentity identity, uint size); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandleEx( + SafeFileHandle handle, int informationClass, out AttributeTag attributes, uint size); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandleW(SafeFileHandle handle, StringBuilder path, uint size, uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileInformationByHandle(SafeFileHandle handle, int informationClass, ref uint flags, uint size); + } + } +} diff --git a/GVFS/GVFS.Platform.Windows/WindowsDehydrateBackup.cs b/GVFS/GVFS.Platform.Windows/WindowsDehydrateBackup.cs new file mode 100644 index 0000000000..d259255131 --- /dev/null +++ b/GVFS/GVFS.Platform.Windows/WindowsDehydrateBackup.cs @@ -0,0 +1,408 @@ +#nullable enable + +using GVFS.Common.Tracing; +using Microsoft.Win32.SafeHandles; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Threading; + +namespace GVFS.Platform.Windows +{ + /// + /// Deletes an explicitly authorized timestamped backup, never the common backup root. + /// The caller owns backup selection, user consent, and successful-dehydrate eligibility. + /// A timestamp-shaped name or file identity alone does not identify a completed backup. + /// + public sealed partial class WindowsDehydrateBackup : IDisposable + { + private const string BackupFolderName = "dehydrate_backup"; + private const int MaximumDepth = 256; + private const int MaximumFailureSamples = 16; + private readonly ITracer tracer; + private readonly string path; + private readonly SafeFileHandle container; + private readonly SafeFileHandle backup; + private int operationInProgress; + private bool disposed; + private bool deleted; + + private WindowsDehydrateBackup( + ITracer tracer, + string path, + SafeFileHandle container, + SafeFileHandle backup, + string identity) + { + this.tracer = tracer; + this.path = path; + this.container = container; + this.backup = backup; + this.Identity = identity; + } + + public string Identity { get; } + + /// + /// The local, non-drive-root directory that contains dehydrate_backup. + /// For a linked worktree, pass the directory that contains the worktree, not the primary enlistment. + /// + public static WindowsDehydrateBackup Open( + ITracer tracer, + string backupParentRoot, + string backupName, + string? expectedIdentity = null, + string? worktreeName = null) + { + ArgumentNullException.ThrowIfNull(tracer); + ArgumentException.ThrowIfNullOrWhiteSpace(backupParentRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(backupName); + if (!Path.IsPathFullyQualified(backupParentRoot) || + !DateTime.TryParseExact( + backupName, + new[] { "yyyyMMdd_HHmmss_fffffff", "yyyyMMdd_HHmmss" }, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _) || + (worktreeName != null && + (string.IsNullOrWhiteSpace(worktreeName) || worktreeName == "." || worktreeName == ".." || + worktreeName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 || + worktreeName.EndsWith(".", StringComparison.Ordinal) || worktreeName.EndsWith(" ", StringComparison.Ordinal)))) + { + throw new ArgumentException("Specify an absolute backup parent path and a timestamped backup name."); + } + + string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(backupParentRoot)); + if (root.Length <= 3 || root[1] != ':' || root[2] != Path.DirectorySeparatorChar) + { + throw new ArgumentException("Specify a local backup parent directory, not a drive root or network path.", nameof(backupParentRoot)); + } + + string containerPath = Path.Combine(root, BackupFolderName); + string backupPath = worktreeName == null + ? Path.Combine(containerPath, backupName) + : Path.Combine(containerPath, worktreeName, backupName); + SafeFileHandle? container = null; + SafeFileHandle? worktreeContainer = null; + SafeFileHandle? backup = null; + bool opened = false; + try + { + container = Native.OpenContainer(containerPath); + Native.FileIdentity containerIdentity = Native.GetIdentity(container); + Native.VerifyOrdinaryDirectory(container); + Native.VerifyFinalPath(container, containerPath); + if (worktreeName != null) + { + worktreeContainer = Native.OpenChild(container, worktreeName, directory: true, forDeletion: false); + Native.VerifyOrdinaryDirectory(worktreeContainer); + container.Dispose(); + container = worktreeContainer; + worktreeContainer = null; + } + + backup = Native.OpenChild(container, backupName, directory: true); + Native.VerifyOrdinaryDirectory(backup); + Native.FileIdentity identity = Native.GetIdentity(backup); + if (identity.Volume != containerIdentity.Volume || + (expectedIdentity != null && !string.Equals(expectedIdentity, identity.ToString(), StringComparison.Ordinal))) + { + throw new IOException("The backup identity does not match the authorized backup."); + } + + WindowsDehydrateBackup scope = new WindowsDehydrateBackup(tracer, backupPath, container, backup, identity.ToString()); + opened = true; + return scope; + } + catch (Exception exception) when (exception is IOException || exception is Win32Exception) + { + tracer.RelatedWarning(new EventMetadata + { + { "BackupPath", backupPath }, + { "Exception", exception.ToString() }, + }, "Failed to open the authorized dehydrate backup."); + throw; + } + finally + { + worktreeContainer?.Dispose(); + if (!opened) + { + backup?.Dispose(); + container?.Dispose(); + } + } + } + + /// + /// Reports progress after each deleted handle closes. Callback errors stop deletion and propagate to the caller. + /// Concurrent or reentrant calls to Delete or Dispose are not supported. + /// + public DeletionResult Delete(CancellationToken cancellationToken = default, Action? progress = null) + { + this.EnterOperation(); + try + { + ObjectDisposedException.ThrowIf(this.disposed, this); + DeletionResult result = new DeletionResult(); + if (this.deleted) + { + result.BackupDeleted = true; + return result; + } + + if (this.DeleteDirectory(this.backup, this.path, depth: 0, cancellationToken, progress, result)) + { + this.backup.Dispose(); + this.deleted = true; + result.BackupDeleted = true; + progress?.Invoke(new DeletionProgress(result.FilesDeleted, result.DirectoriesDeleted)); + } + + return result; + } + finally + { + Volatile.Write(ref this.operationInProgress, 0); + } + } + + public void Dispose() + { + this.EnterOperation(); + try + { + if (!this.disposed) + { + this.backup.Dispose(); + this.container.Dispose(); + this.disposed = true; + } + } + finally + { + Volatile.Write(ref this.operationInProgress, 0); + } + } + + private void EnterOperation() + { + if (Interlocked.CompareExchange(ref this.operationInProgress, 1, 0) != 0) + { + throw new InvalidOperationException("Backup cleanup does not permit concurrent or reentrant operations."); + } + } + + private bool DeleteDirectory( + SafeFileHandle directory, + string directoryPath, + int depth, + CancellationToken cancellationToken, + Action? progress, + DeletionResult result) + { + if (this.CheckCancellation(cancellationToken, result)) + { + return false; + } + + if (depth >= MaximumDepth) + { + this.RecordFailure(result, directoryPath, new IOException("The backup exceeds the supported directory depth.")); + return false; + } + + bool childrenDeleted = true; + using (IEnumerator entries = Native.EnumeratePhysicalEntries(directory).GetEnumerator()) + { + while (true) + { + if (this.CheckCancellation(cancellationToken, result)) + { + return false; + } + + try + { + if (!entries.MoveNext()) + { + break; + } + } + catch (Exception exception) when (exception is Win32Exception || exception is IOException) + { + this.RecordFailure(result, directoryPath, exception); + return false; + } + + if (this.CheckCancellation(cancellationToken, result)) + { + return false; + } + + childrenDeleted &= this.DeleteEntry( + directory, directoryPath, entries.Current, depth, cancellationToken, progress, result); + } + } + + return !this.CheckCancellation(cancellationToken, result) && childrenDeleted && + this.TryMarkForDeletion(directory, directoryPath, isDirectory: true, result); + } + + private bool DeleteEntry( + SafeFileHandle directory, + string directoryPath, + Native.Entry entry, + int depth, + CancellationToken cancellationToken, + Action? progress, + DeletionResult result) + { + string childPath = Path.Combine(directoryPath, entry.Name); + SafeFileHandle? child = null; + bool entryDeleted; + try + { + bool recurse; + try + { + child = Native.OpenChild(directory, entry.Name, entry.IsDirectory); + Native.FileIdentity identity = Native.GetIdentity(child); + Native.VerifySupportedReparsePoint(child); + if (identity.FileId != entry.FileId) + { + throw new IOException("The backup entry changed after enumeration."); + } + + recurse = entry.IsDirectory && !Native.IsTombstone(child); + } + catch (Win32Exception exception) when (exception.NativeErrorCode == 2 || exception.NativeErrorCode == 3) + { + ++result.EntriesAlreadyAbsent; + return true; + } + catch (Exception exception) when (exception is Win32Exception || exception is IOException) + { + this.RecordFailure(result, childPath, exception); + return false; + } + + entryDeleted = recurse + ? this.DeleteDirectory(child, childPath, depth + 1, cancellationToken, progress, result) + : this.TryMarkForDeletion(child, childPath, entry.IsDirectory, result); + } + finally + { + child?.Dispose(); + } + + if (entryDeleted) + { + progress?.Invoke(new DeletionProgress(result.FilesDeleted, result.DirectoriesDeleted)); + } + + return entryDeleted; + } + + private bool TryMarkForDeletion(SafeFileHandle handle, string entryPath, bool isDirectory, DeletionResult result) + { + try + { + Native.MarkForDeletion(handle); + } + catch (Exception exception) when (exception is Win32Exception || exception is IOException) + { + this.RecordFailure(result, entryPath, exception); + return false; + } + + if (isDirectory) + { + ++result.DirectoriesDeleted; + } + else + { + ++result.FilesDeleted; + } + + return true; + } + + private bool CheckCancellation(CancellationToken cancellationToken, DeletionResult result) + { + if (cancellationToken.IsCancellationRequested) + { + result.Cancelled = true; + return true; + } + + return false; + } + + private void RecordFailure(DeletionResult result, string failurePath, Exception exception) + { + ++result.FailureCount; + result.AddFailure(new DeletionFailure(failurePath, exception)); + + this.tracer.RelatedWarning(new EventMetadata + { + { "BackupPath", this.path }, + { "Path", failurePath }, + { "Exception", exception.ToString() }, + }, "Dehydrate backup cleanup retained an entry."); + } + + public sealed class DeletionResult + { + private readonly List failureSamples = new List(); + + internal DeletionResult() + { + this.FailureSamples = this.failureSamples.AsReadOnly(); + } + + public bool BackupDeleted { get; internal set; } + public bool Cancelled { get; internal set; } + public long FilesDeleted { get; internal set; } + public long DirectoriesDeleted { get; internal set; } + public long EntriesAlreadyAbsent { get; internal set; } + public long FailureCount { get; internal set; } + public IReadOnlyList FailureSamples { get; } + public bool Succeeded => this.BackupDeleted && !this.Cancelled && this.FailureCount == 0; + + internal void AddFailure(DeletionFailure failure) + { + if (this.failureSamples.Count < MaximumFailureSamples) + { + this.failureSamples.Add(failure); + } + } + } + + public sealed class DeletionFailure + { + internal DeletionFailure(string path, Exception exception) + { + this.Path = path; + this.Exception = exception; + } + + public string Path { get; } + public Exception Exception { get; } + } + + public readonly struct DeletionProgress + { + internal DeletionProgress(long filesDeleted, long directoriesDeleted) + { + this.FilesDeleted = filesDeleted; + this.DirectoriesDeleted = directoriesDeleted; + } + + public long FilesDeleted { get; } + public long DirectoriesDeleted { get; } + } + } +}