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
25 changes: 15 additions & 10 deletions FileDeduplicator.Test/DeduplicatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ public void DeleteDuplicatesRemovesEveryCopyExceptTheKeeper()
AbsoluteFilePath keeper = Deduplicator.SelectFileToKeep(duplicates[0].Files);

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert
Assert.AreEqual(2, result.DeletedCount);
Expand Down Expand Up @@ -223,7 +223,7 @@ public void EmptyFilesAreNeverDeleted()
IReadOnlyList<DuplicateGroup> duplicates = Duplicates(FileHasher.HashFiles([initA, initB, copy, keeper]));

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert
Assert.HasCount(2, duplicates, "The empty files are still reported as a group.");
Expand Down Expand Up @@ -253,7 +253,7 @@ public void ReclaimedBytesCountsOnlyTheDeletedCopies()
IReadOnlyList<DuplicateGroup> duplicates = Duplicates(hashes);

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert -- three copies, two deleted, 100 bytes apiece
Assert.AreEqual(2, result.DeletedCount);
Expand All @@ -280,7 +280,7 @@ public void AFileThatChangedAfterGroupingIsPreservedAndReported()
_ = tree.Write("bb.txt", "no longer the same content");

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert
Assert.IsTrue(TempTree.Exists(changed), "A file that is no longer a duplicate must not be deleted.");
Expand Down Expand Up @@ -311,7 +311,7 @@ public void AChangedKeeperPreservesTheWholeGroup()
_ = tree.Write("a.txt", "the keeper was overwritten");

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert
Assert.AreEqual(0, result.DeletedCount);
Expand Down Expand Up @@ -340,6 +340,7 @@ public void AKeeperChangedDuringTheDeleteLoopPreservesTheRemainingCopies()
// Act -- the keeper is rewritten right after the first deletion, as a sync client would
DeduplicationResult result = Deduplicator.DeleteDuplicates(
duplicates,
tree.Root,
deleted =>
{
if (deleted == first)
Expand Down Expand Up @@ -375,6 +376,7 @@ public void AKeeperRewrittenWithoutChangingSizeOrTimeIsReportedAfterTheGroup()
// Act -- same length, timestamp put back
DeduplicationResult result = Deduplicator.DeleteDuplicates(
duplicates,
tree.Root,
_ =>
{
File.WriteAllText(keeper.WeakString, "SHARED");
Expand Down Expand Up @@ -404,7 +406,7 @@ public void AFileThatVanishedBeforeDeletionIsReportedNotCounted()
File.Delete(vanishing.WeakString);

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert
Assert.AreEqual(0, result.DeletedCount);
Expand Down Expand Up @@ -434,7 +436,7 @@ public void APathReplacedByADirectoryIsSkippedRatherThanDeleted()
_ = Directory.CreateDirectory(replaced.WeakString);

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert
Assert.AreEqual(0, result.DeletedCount);
Expand Down Expand Up @@ -462,7 +464,7 @@ public void UntouchedDuplicatesAreDeletedWithNothingSkipped()
IReadOnlyList<DuplicateGroup> duplicates = Duplicates(hashes);

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert
Assert.AreEqual(2, result.DeletedCount);
Expand Down Expand Up @@ -496,7 +498,7 @@ public void ACopyThatCannotBeDeletedIsReportedAndTheRunCarriesOn()
}

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert -- the refusal is reported, not thrown
Assert.ContainsSingle(result.Errors);
Expand All @@ -517,8 +519,11 @@ public void ACopyThatCannotBeDeletedIsReportedAndTheRunCarriesOn()
[TestMethod]
public void DeletingAnEmptyGroupListIsANoOp()
{
// Arrange
using TempTree tree = new();

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates([]);
DeduplicationResult result = Deduplicator.DeleteDuplicates([], tree.Root);

// Assert
Assert.AreEqual(0, result.DeletedCount);
Expand Down
8 changes: 4 additions & 4 deletions FileDeduplicator.Test/DryRunEquivalenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public void PredictedDeletionsMatchActualDeletionsExactly()
HashSet<AbsoluteFilePath> predicted = PredictedDeletions(duplicates);

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates);
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);
HashSet<AbsoluteFilePath> actuallyDeleted = [.. all.Where(f => !TempTree.Exists(f))];

// Assert
Expand Down Expand Up @@ -104,7 +104,7 @@ public void EveryDuplicateGroupRetainsExactlyOneSurvivor()
IReadOnlyList<DuplicateGroup> duplicates = Deduplicator.FindDuplicates(Deduplicator.GroupByHash(hashes));

// Act
_ = Deduplicator.DeleteDuplicates(duplicates);
_ = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert
foreach (DuplicateGroup group in duplicates)
Expand All @@ -131,13 +131,13 @@ public void DeduplicatingATreeTwiceDeletesNothingTheSecondTime()
IReadOnlyList<AbsoluteFilePath> firstScan = FileScanner.ScanForFiles(tree.Root);
IReadOnlyList<DuplicateGroup> firstDuplicates =
Deduplicator.FindDuplicates(Deduplicator.GroupByHash(FileHasher.HashFiles(firstScan)));
DeduplicationResult first = Deduplicator.DeleteDuplicates(firstDuplicates);
DeduplicationResult first = Deduplicator.DeleteDuplicates(firstDuplicates, tree.Root);

// Act -- second pass over the now-deduplicated tree
IReadOnlyList<AbsoluteFilePath> secondScan = FileScanner.ScanForFiles(tree.Root);
IReadOnlyList<DuplicateGroup> secondDuplicates =
Deduplicator.FindDuplicates(Deduplicator.GroupByHash(FileHasher.HashFiles(secondScan)));
DeduplicationResult second = Deduplicator.DeleteDuplicates(secondDuplicates);
DeduplicationResult second = Deduplicator.DeleteDuplicates(secondDuplicates, tree.Root);

// Assert
Assert.AreEqual(1, first.DeletedCount);
Expand Down
2 changes: 1 addition & 1 deletion FileDeduplicator.Test/SameFileTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ public void DeletionSkipsAPathThatLeadsToTheKeeper()
DuplicateGroup group = new(hash, [keeper, link], "content".Length);

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates([group]);
DeduplicationResult result = Deduplicator.DeleteDuplicates([group], tree.Root);

// Assert
Assert.AreEqual(0, result.DeletedCount);
Expand Down
180 changes: 180 additions & 0 deletions FileDeduplicator.Test/ScannedRootContainmentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.FileDeduplicator.Test;

using ktsu.Semantics.Paths;
using ktsu.Semantics.Strings;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Deletion goes through path strings recorded at scan time. These tests check that a path which
/// no longer leads inside the scanned folder is never deleted through.
/// </summary>
[TestClass]
public sealed class ScannedRootContainmentTests
{
/// <summary>
/// A subdirectory replaced by a link to a folder outside the root during the confirmation
/// prompt must not let the delete reach an identical file out there.
/// </summary>
[TestMethod]
public void ASubdirectorySwappedForALinkOutsideTheRootIsNotDeletedThrough()
{
// Arrange -- a.cfg is kept, sub/settings.cfg is up for deletion
using TempTree tree = new();
using TempTree outside = new();
AbsoluteFilePath keeper = tree.Write("a.cfg", "important config v1");
AbsoluteFilePath copy = tree.Write("sub/settings.cfg", "important config v1");
AbsoluteFilePath outsideFile = outside.Write("settings.cfg", "important config v1");
IReadOnlyList<DuplicateGroup> duplicates = Deduplicator.FindDuplicates(Deduplicator.GroupByHash(FileHasher.HashFiles([keeper, copy])));

// Arrange -- while the prompt is open, sub/ becomes a link to the outside folder
string sub = Path.Combine(tree.Root.WeakString, "sub");
Directory.Delete(sub, recursive: true);
CreateDirectoryLinkOrSkip(sub, outside.Root.WeakString);

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, tree.Root);

// Assert
Assert.IsTrue(TempTree.Exists(outsideFile), "A file outside the scanned folder must never be deleted.");
Assert.IsTrue(TempTree.Exists(keeper), "The keeper must survive.");
Assert.AreEqual(0, result.DeletedCount);
Assert.ContainsSingle(result.SkippedFiles);
Assert.AreEqual(copy, result.SkippedFiles[0].Path);
Assert.Contains("scanned folder", result.SkippedFiles[0].Reason);
}

/// <summary>
/// The root itself may be reached through a link, as when a user passes a symlinked home
/// folder. That alone must not stop copies under it from being deleted.
/// </summary>
[TestMethod]
public void ARootReachedThroughALinkStillDeletesItsDuplicates()
{
// Arrange
using TempTree tree = new();
using TempTree links = new();
string linkedRoot = Path.Combine(links.Root.WeakString, "linked-root");
CreateDirectoryLinkOrSkip(linkedRoot, tree.Root.WeakString);
AbsoluteDirectoryPath root = linkedRoot.As<AbsoluteDirectoryPath>();
AbsoluteFilePath keeper = Path.Combine(linkedRoot, "a.txt").As<AbsoluteFilePath>();
AbsoluteFilePath copy = Path.Combine(linkedRoot, "nested", "bb.txt").As<AbsoluteFilePath>();
_ = tree.Write("a.txt", "shared");
_ = tree.Write("nested/bb.txt", "shared");
IReadOnlyList<DuplicateGroup> duplicates = Deduplicator.FindDuplicates(Deduplicator.GroupByHash(FileHasher.HashFiles([keeper, copy])));

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, root);

// Assert
Assert.AreEqual(1, result.DeletedCount);
Assert.IsEmpty(result.SkippedFiles);
Assert.IsFalse(TempTree.Exists(copy), "A copy inside the root should still be deleted.");
}

/// <summary>
/// Resolution follows a relative link and a link inside a link's target.
/// </summary>
[TestMethod]
public void RealPathFollowsNestedRelativeLinks()
{
// Arrange -- first -> second (relative), second -> target (absolute)
using TempTree tree = new();
_ = tree.Write("target/file.txt", "x");
string target = Path.Combine(tree.Root.WeakString, "target");
string second = Path.Combine(tree.Root.WeakString, "second");
string first = Path.Combine(tree.Root.WeakString, "first");
CreateDirectoryLinkOrSkip(second, target);
CreateDirectoryLinkOrSkip(first, "second");
Assert.IsTrue(RealPath.TryResolve(tree.Root.WeakString, out string realRoot));

// Act
bool resolved = RealPath.TryResolve(Path.Combine(first, "file.txt"), out string real);

// Assert
Assert.IsTrue(resolved);
Assert.AreEqual(Path.Combine(realRoot, "target", "file.txt"), real);
Assert.IsTrue(RealPath.IsWithin(real, realRoot));
Assert.IsFalse(RealPath.IsWithin(realRoot + "-sibling", realRoot), "A sibling sharing the root's name as a prefix is not inside it.");
}

/// <summary>
/// A link loop never resolves, so a scanned root reached through one leaves every copy on disk
/// with a reason, rather than deleting on an unchecked path.
/// </summary>
[TestMethod]
public void AnUnresolvableRootSkipsEveryCopy()
{
// Arrange -- loop-a -> loop-b -> loop-a
using TempTree tree = new();
AbsoluteFilePath keeper = tree.Write("a.txt", "shared");
AbsoluteFilePath copy = tree.Write("bb.txt", "shared");
IReadOnlyList<DuplicateGroup> duplicates = Deduplicator.FindDuplicates(Deduplicator.GroupByHash(FileHasher.HashFiles([keeper, copy])));
string loopA = Path.Combine(tree.Root.WeakString, "loop-a");
string loopB = Path.Combine(tree.Root.WeakString, "loop-b");
CreateDirectoryLinkOrSkip(loopA, loopB);
CreateDirectoryLinkOrSkip(loopB, loopA);

// Act
DeduplicationResult result = Deduplicator.DeleteDuplicates(duplicates, loopA.As<AbsoluteDirectoryPath>());

// Assert
Assert.AreEqual(0, result.DeletedCount);
Assert.IsTrue(TempTree.Exists(copy), "A copy must not be deleted when the root cannot be resolved.");
Assert.ContainsSingle(result.SkippedFiles);
Assert.Contains("could not be resolved", result.SkippedFiles[0].Reason);
}

/// <summary>
/// Resolution refuses a relative path, gives up on a link loop, and handles "." and ".."
/// segments without leaving the real directory tree.
/// </summary>
[TestMethod]
public void RealPathHandlesDotSegmentsLoopsAndRelativeInput()
{
// Arrange
using TempTree tree = new();
_ = tree.Write("dir/file.txt", "x");
Assert.IsTrue(RealPath.TryResolve(tree.Root.WeakString, out string realRoot));
string loopA = Path.Combine(tree.Root.WeakString, "loop-a");
string loopB = Path.Combine(tree.Root.WeakString, "loop-b");
CreateDirectoryLinkOrSkip(loopA, loopB);
CreateDirectoryLinkOrSkip(loopB, loopA);
string dotted = tree.Root.WeakString + $"{Path.DirectorySeparatorChar}.{Path.DirectorySeparatorChar}dir{Path.DirectorySeparatorChar}..{Path.DirectorySeparatorChar}dir{Path.DirectorySeparatorChar}file.txt";

// Act + Assert
Assert.IsFalse(RealPath.TryResolve(Path.Combine("relative", "path"), out _), "A relative path has no single meaning to resolve.");
Assert.IsFalse(RealPath.TryResolve(Path.Combine(loopA, "file.txt"), out _), "A link loop never resolves.");
Assert.IsTrue(RealPath.TryResolve(dotted, out string real));
Assert.AreEqual(Path.Combine(realRoot, "dir", "file.txt"), real);
}

/// <summary>
/// A file-system root contains everything on it, including paths that do not repeat its
/// trailing separator.
/// </summary>
[TestMethod]
public void EverythingIsWithinAFileSystemRoot()
{
// Arrange
string root = Path.GetPathRoot(Path.GetTempPath())!;

// Act + Assert
Assert.IsTrue(RealPath.IsWithin(Path.Combine(root, "any", "where"), root));
Assert.IsTrue(RealPath.IsWithin(root, root));
}

private static void CreateDirectoryLinkOrSkip(string path, string target)
{
try
{
_ = Directory.CreateSymbolicLink(path, target);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Windows without Developer Mode or elevation refuses to create symbolic links.
Assert.Inconclusive($"Cannot create a symbolic link here: {ex.Message}");
}
}
}
Loading
Loading