From e142c66d58b874c255036c22c9b3a5a6949e5cbd Mon Sep 17 00:00:00 2001 From: Tyrie Vella Date: Fri, 30 Oct 2026 11:05:56 -0700 Subject: [PATCH] Add a physical-only engine for dehydrate backup deletion Enumerate on-disk entries through native handles so projected-only names cannot block backup deletion while the original provider remains active. Pin the selected backup, reject replaced entries and unrelated reparse points, and preserve explicit cancellation and retry results. Preserve raw UTF-16 names and report progress after handles close. Propagate callback errors, reject reentrant operations, and delete names despite compatible readers. Add separate white-box functional fixtures for filesystem and provider integration. Run client operations in a separate test process and verify notification delivery with a live-file control. Exercise cold reads, file and directory tombstones, scope guards, and bounded failure samples. Pin control-file handles before deletion to reject fixture aliases. Document the white-box boundary and the backup-parent contract. Keep the engine dormant until a separate product caller exposes cleanup. Assisted-by: GPT-6.1 Sol Signed-off-by: Tyrie Vella --- AuthoringTests.md | 16 + CONTRIBUTING.md | 6 +- GVFS/GVFS.FunctionalTests/Categories.cs | 1 + .../GVFS.FunctionalTests.csproj | 3 + GVFS/GVFS.FunctionalTests/Program.cs | 6 + .../WhiteBox/DehydrateBackupDeletionTests.cs | 386 +++++++++++ .../Tests/WhiteBox/DehydrateBackupProcess.cs | 174 +++++ .../WhiteBox/WindowsDehydrateBackupTests.cs | 632 ++++++++++++++++++ .../WindowsDehydrateBackup.Native.cs | 356 ++++++++++ .../WindowsDehydrateBackup.cs | 408 +++++++++++ 10 files changed, 1987 insertions(+), 1 deletion(-) create mode 100644 GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/DehydrateBackupDeletionTests.cs create mode 100644 GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/DehydrateBackupProcess.cs create mode 100644 GVFS/GVFS.FunctionalTests/Windows/Tests/WhiteBox/WindowsDehydrateBackupTests.cs create mode 100644 GVFS/GVFS.Platform.Windows/WindowsDehydrateBackup.Native.cs create mode 100644 GVFS/GVFS.Platform.Windows/WindowsDehydrateBackup.cs 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; } + } + } +}