Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion FileDeduplicator.Test/DeduplicatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ public void AFileThatVanishedBeforeDeletionIsReportedNotCounted()
/// <summary>
/// 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
/// <see cref="UnauthorizedAccessException"/>, which the delete path does not catch.
/// <see cref="UnauthorizedAccessException"/> and report the directory as a failed deletion,
/// which says nothing about why the path was left alone.
/// </summary>
[TestMethod]
public void APathReplacedByADirectoryIsSkippedRatherThanDeleted()
Expand Down Expand Up @@ -375,6 +376,47 @@ public void UntouchedDuplicatesAreDeletedWithNothingSkipped()
Assert.IsEmpty(result.Errors);
}

/// <summary>
/// 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.
/// </summary>
[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<DuplicateGroup> 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.");
}

/// <summary>
/// Deleting nothing must report nothing, rather than throwing on an empty group list.
/// </summary>
Expand Down
113 changes: 113 additions & 0 deletions FileDeduplicator.Test/DeletionBlock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.FileDeduplicator.Test;

using ktsu.Semantics.Paths;

/// <summary>
/// Makes a file refuse to be deleted for the lifetime of the block, and puts the permissions back
/// on disposal.
/// </summary>
/// <remarks>
/// The two platforms refuse for different reasons. Windows will not unlink a file carrying
/// <see cref="FileAttributes.ReadOnly"/>. 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 <see cref="UnauthorizedAccessException"/> out of
/// <see cref="File.Delete(string)"/>, which is the failure the delete path has to survive.
/// </remarks>
internal sealed class DeletionBlock : IDisposable
{
private readonly string file;
private readonly string directory;
private readonly string probe;
private readonly UnixFileMode originalDirectoryMode;

/// <summary>
/// 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.
/// </summary>
internal bool IsEnforced { get; }

/// <summary>
/// Blocks deletion of a file, then measures whether the block took effect.
/// </summary>
/// <param name="target">The file to protect.</param>
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);
}
}

/// <summary>
/// Spends a throwaway file to find out whether this process is actually refused, rather than
/// guessing from the platform and the user id.
/// </summary>
/// <returns><see langword="true"/> if deleting the probe was refused.</returns>
private bool ProbeRefusesDeletion()
{
try
{
File.Delete(probe);
return false;
}
catch (UnauthorizedAccessException)
{
return true;
}
}

/// <inheritdoc />
public void Dispose()
{
Unblock();

if (File.Exists(probe))
{
File.Delete(probe);
}
}
}
10 changes: 10 additions & 0 deletions FileDeduplicator/Deduplicator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ internal static DeduplicationResult DeleteDuplicates(IReadOnlyList<DuplicateGrou
errors.Add(error);
Console.WriteLine(error);
}
// A copy the process is not allowed to remove -- read-only on Windows, or in a
// write-protected directory on Unix -- must cost that one file, not the rest of the
// run. Letting this escape would abandon every group after it, with no summary and
// no report of what was already deleted.
catch (UnauthorizedAccessException ex)
{
string error = $" Error deleting {file}: {ex.Message}";
errors.Add(error);
Console.WriteLine(error);
}
}
}

Expand Down