diff --git a/FileDeduplicator.Test/DeduplicatorTests.cs b/FileDeduplicator.Test/DeduplicatorTests.cs index dea0d86..f792498 100644 --- a/FileDeduplicator.Test/DeduplicatorTests.cs +++ b/FileDeduplicator.Test/DeduplicatorTests.cs @@ -323,7 +323,8 @@ public void AFileThatVanishedBeforeDeletionIsReportedNotCounted() /// /// A path whose file was replaced by a directory of the same name cannot be re-read, so it /// must be skipped. Attempting the delete instead would raise - /// , which the delete path does not catch. + /// and report the directory as a failed deletion, + /// which says nothing about why the path was left alone. /// [TestMethod] public void APathReplacedByADirectoryIsSkippedRatherThanDeleted() @@ -375,6 +376,47 @@ public void UntouchedDuplicatesAreDeletedWithNothingSkipped() Assert.IsEmpty(result.Errors); } + /// + /// A copy the process is not allowed to remove must cost that one file. It is reported as an + /// error, the group it belongs to carries on, and so does every group after it -- otherwise a + /// single read-only duplicate aborts a destructive run partway through, with files already + /// deleted and no summary saying which. + /// + [TestMethod] + public void ACopyThatCannotBeDeletedIsReportedAndTheRunCarriesOn() + { + // Arrange -- two groups, one of which holds a copy that cannot be removed + using TempTree tree = new(); + AbsoluteFilePath keeper = tree.Write("a.txt", "shared"); + AbsoluteFilePath undeletable = tree.Write("protected/bbb.txt", "shared"); + AbsoluteFilePath otherKeeper = tree.Write("x.txt", "other content"); + AbsoluteFilePath otherCopy = tree.Write("yy.txt", "other content"); + IReadOnlyList duplicates = Duplicates(FileHasher.HashFiles([keeper, undeletable, otherKeeper, otherCopy])); + Assert.HasCount(2, duplicates, "The two contents should form two separate groups."); + + using DeletionBlock block = new(undeletable); + + if (!block.IsEnforced) + { + Assert.Inconclusive("This process deletes through a write-protected directory, so the refusal under test cannot be staged. Run the tests as an unprivileged user."); + } + + // Act + DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates); + + // Assert -- the refusal is reported, not thrown + Assert.ContainsSingle(result.Errors); + Assert.Contains(undeletable.WeakString, result.Errors[0]); + Assert.IsTrue(TempTree.Exists(undeletable), "A copy that could not be deleted must still be on disk."); + Assert.IsEmpty(result.SkippedFiles, "The file was a genuine duplicate; it failed to delete rather than failing verification."); + + // Assert -- the rest of the work still happened + Assert.AreEqual(1, result.DeletedCount); + Assert.IsFalse(TempTree.Exists(otherCopy), "An unrelated duplicate group must still be deduplicated."); + Assert.IsTrue(TempTree.Exists(keeper), "The keeper must survive."); + Assert.IsTrue(TempTree.Exists(otherKeeper), "The keeper must survive."); + } + /// /// Deleting nothing must report nothing, rather than throwing on an empty group list. /// diff --git a/FileDeduplicator.Test/DeletionBlock.cs b/FileDeduplicator.Test/DeletionBlock.cs new file mode 100644 index 0000000..09ee3ea --- /dev/null +++ b/FileDeduplicator.Test/DeletionBlock.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.FileDeduplicator.Test; + +using ktsu.Semantics.Paths; + +/// +/// Makes a file refuse to be deleted for the lifetime of the block, and puts the permissions back +/// on disposal. +/// +/// +/// The two platforms refuse for different reasons. Windows will not unlink a file carrying +/// . Unix ignores that bit when deleting -- unlink is governed +/// by write permission on the containing directory -- so the directory is what gets write-protected +/// there. Both surface as out of +/// , which is the failure the delete path has to survive. +/// +internal sealed class DeletionBlock : IDisposable +{ + private readonly string file; + private readonly string directory; + private readonly string probe; + private readonly UnixFileMode originalDirectoryMode; + + /// + /// Gets whether the block actually holds. A process running as root deletes through + /// write-protected directories regardless, so the staged failure never happens and a test + /// relying on it has nothing to observe. + /// + internal bool IsEnforced { get; } + + /// + /// Blocks deletion of a file, then measures whether the block took effect. + /// + /// The file to protect. + internal DeletionBlock(AbsoluteFilePath target) + { + file = target.WeakString; + directory = Path.GetDirectoryName(file)!; + probe = Path.Combine(directory, $"{Path.GetFileName(file)}.deletion-probe"); + + // Written before the block goes on, because on Unix the block closes the directory to new + // files as well as to deletions. + File.WriteAllText(probe, "probe"); + + originalDirectoryMode = OperatingSystem.IsWindows() ? default : File.GetUnixFileMode(directory); + Block(); + IsEnforced = ProbeRefusesDeletion(); + } + + private void Block() + { + if (OperatingSystem.IsWindows()) + { + File.SetAttributes(file, File.GetAttributes(file) | FileAttributes.ReadOnly); + File.SetAttributes(probe, File.GetAttributes(probe) | FileAttributes.ReadOnly); + } + else + { + File.SetUnixFileMode(directory, originalDirectoryMode & ~(UnixFileMode.UserWrite | UnixFileMode.GroupWrite | UnixFileMode.OtherWrite)); + } + } + + private void Unblock() + { + if (OperatingSystem.IsWindows()) + { + ClearReadOnly(file); + ClearReadOnly(probe); + } + else + { + File.SetUnixFileMode(directory, originalDirectoryMode); + } + } + + private static void ClearReadOnly(string path) + { + if (File.Exists(path)) + { + File.SetAttributes(path, File.GetAttributes(path) & ~FileAttributes.ReadOnly); + } + } + + /// + /// Spends a throwaway file to find out whether this process is actually refused, rather than + /// guessing from the platform and the user id. + /// + /// if deleting the probe was refused. + private bool ProbeRefusesDeletion() + { + try + { + File.Delete(probe); + return false; + } + catch (UnauthorizedAccessException) + { + return true; + } + } + + /// + public void Dispose() + { + Unblock(); + + if (File.Exists(probe)) + { + File.Delete(probe); + } + } +} diff --git a/FileDeduplicator/Deduplicator.cs b/FileDeduplicator/Deduplicator.cs index 3975caa..e43b444 100644 --- a/FileDeduplicator/Deduplicator.cs +++ b/FileDeduplicator/Deduplicator.cs @@ -82,6 +82,16 @@ internal static DeduplicationResult DeleteDuplicates(IReadOnlyList