diff --git a/.gitignore b/.gitignore
index dc0470a..e043c9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -203,6 +203,11 @@ PublishScripts/
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
+# and except a Unity project's Packages/, which is source: Unity's package manifest and its
+# resolved lock file are both meant to be committed, and a NuGet restore folder never contains
+# a file by either name.
+!**/[Pp]ackages/manifest.json
+!**/[Pp]ackages/packages-lock.json
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
@@ -651,3 +656,16 @@ Temporary Items
# ImGui.ini files
imgui.ini
+
+# Game engine projects
+#
+# Godot: the import cache, and the mono/temp bin+obj a C# build writes.
+.godot/
+
+# Unity: .meta files are source, not the Visual Studio C++ build artifact that the `*.meta` rule
+# further up targets. Unity generates one per asset and it carries the GUID that scenes, prefabs
+# and serialized references point at, so ignoring them gives every clone fresh GUIDs and silently
+# breaks those references - including for a plug-in whose .dll is itself a build output. This
+# negation has to come after that rule to win, and is scoped to the asset tree so the Visual
+# Studio artifact stays ignored everywhere else.
+!**/[Aa]ssets/**/*.meta
diff --git a/FileDeduplicator.Test/DirectoryReadBlock.cs b/FileDeduplicator.Test/DirectoryReadBlock.cs
new file mode 100644
index 0000000..3dae52c
--- /dev/null
+++ b/FileDeduplicator.Test/DirectoryReadBlock.cs
@@ -0,0 +1,75 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.FileDeduplicator.Test;
+
+///
+/// Makes a directory refuse to be listed for the lifetime of the block, and puts the permissions
+/// back on disposal.
+///
+///
+/// The Unix arrangement is to drop read and execute on the directory, which makes
+/// throw --
+/// the failure a scan descending into someone else's folder, or an OS-protected one, has to
+/// survive. Windows refuses a listing only through an ACL deny entry rather than a file attribute,
+/// so nothing is staged there and reports false, the same way it does for a
+/// privileged process.
+///
+internal sealed class DirectoryReadBlock : IDisposable
+{
+ private readonly string directory;
+ private readonly UnixFileMode originalMode;
+
+ ///
+ /// Gets whether the block actually holds. A process running as root lists unreadable
+ /// directories regardless, so the staged failure never happens and a test relying on it has
+ /// nothing to observe.
+ ///
+ internal bool IsEnforced { get; }
+
+ ///
+ /// Blocks listing of a directory, then measures whether the block took effect.
+ ///
+ /// The directory to protect.
+ internal DirectoryReadBlock(string target)
+ {
+ directory = target;
+
+ if (OperatingSystem.IsWindows())
+ {
+ IsEnforced = false;
+ return;
+ }
+
+ originalMode = File.GetUnixFileMode(directory);
+ File.SetUnixFileMode(directory, UnixFileMode.None);
+ IsEnforced = ListingIsRefused();
+ }
+
+ ///
+ /// Tries the listing the block is meant to prevent, rather than guessing from the platform and
+ /// the user id.
+ ///
+ /// if listing the directory was refused.
+ private bool ListingIsRefused()
+ {
+ try
+ {
+ // Materialized, because the enumerator is lazy and raises nothing until it is walked.
+ _ = Directory.EnumerateFileSystemEntries(directory).ToList();
+ return false;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return true;
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ File.SetUnixFileMode(directory, originalMode);
+ }
+ }
+}
diff --git a/FileDeduplicator.Test/FileScannerAndHasherTests.cs b/FileDeduplicator.Test/FileScannerAndHasherTests.cs
index 9f65b9a..62966c3 100644
--- a/FileDeduplicator.Test/FileScannerAndHasherTests.cs
+++ b/FileDeduplicator.Test/FileScannerAndHasherTests.cs
@@ -66,6 +66,67 @@ public void ScanOfAMissingDirectoryReturnsNothing()
Assert.IsEmpty(files);
}
+ ///
+ /// One unreadable directory must not abort the scan. Enumeration is lazy, so the refusal
+ /// arrives partway through the walk, after files elsewhere in the tree have already been found
+ /// and with the rest still to visit.
+ ///
+ [TestMethod]
+ public void ScanCarriesOnPastADirectoryItCannotRead()
+ {
+ // Arrange -- a readable file on either side of the unreadable directory in the walk
+ using TempTree tree = new();
+ AbsoluteFilePath top = tree.Write("top.txt", "a");
+ AbsoluteFilePath sibling = tree.Write("readable/mid.txt", "b");
+ _ = tree.Write("denied/hidden-from-the-scan.txt", "c");
+ string denied = Path.Combine(tree.Root.WeakString, "denied");
+
+ using DirectoryReadBlock block = new(denied);
+
+ if (!block.IsEnforced)
+ {
+ Assert.Inconclusive("This process lists directories it has no permission to read, so the refusal under test cannot be staged. Run the tests as an unprivileged user.");
+ }
+
+ // Act
+ IReadOnlyList files = FileScanner.ScanForFiles(tree.Root);
+
+ // Assert -- everything readable is still found, and the run did not throw
+ Assert.HasCount(2, files);
+ Assert.Contains(top, files);
+ Assert.Contains(sibling, files);
+ }
+
+ ///
+ /// A directory symlink pointing back at one of its own ancestors must not be followed. Build
+ /// caches and some backup layouts create these, and descending into one does not terminate.
+ ///
+ [TestMethod]
+ public void ScanTerminatesOnADirectorySymlinkCycle()
+ {
+ // Arrange -- a/b/loop -> a, so descending revisits a forever
+ using TempTree tree = new();
+ AbsoluteFilePath real = tree.Write("a/b/real.txt", "content");
+ string ancestor = Path.Combine(tree.Root.WeakString, "a");
+ string loop = Path.Combine(ancestor, "b", "loop");
+
+ try
+ {
+ _ = Directory.CreateSymbolicLink(loop, ancestor);
+ }
+ catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
+ {
+ Assert.Inconclusive($"This process cannot create a directory symlink, so the cycle under test cannot be staged: {ex.Message}");
+ }
+
+ // Act -- hangs rather than returning if the link is followed
+ IReadOnlyList files = FileScanner.ScanForFiles(tree.Root);
+
+ // Assert -- the real file is found once, not once per lap around the cycle
+ Assert.ContainsSingle(files);
+ Assert.Contains(real, files);
+ }
+
///
/// Identical content must hash identically regardless of the file's name or location.
///
diff --git a/FileDeduplicator/FileScanner.cs b/FileDeduplicator/FileScanner.cs
index 9e7f1f5..9c10612 100644
--- a/FileDeduplicator/FileScanner.cs
+++ b/FileDeduplicator/FileScanner.cs
@@ -10,6 +10,34 @@ namespace ktsu.FileDeduplicator;
internal static class FileScanner
{
+ ///
+ /// How the scan walks the tree.
+ ///
+ ///
+ /// Replaces , which resolves to the legacy-compatible
+ /// options carrying IgnoreInaccessible = false. Enumeration is lazy, so the
+ /// from one unreadable subdirectory surfaced partway
+ /// through the walk and abandoned the whole scan -- a restricted share or an OS-protected folder
+ /// anywhere under the root was enough.
+ ///
+ /// is skipped so a directory symlink pointing at one of
+ /// its own ancestors cannot be descended into indefinitely. It skips linked files too, which
+ /// suits a deduplicator: a symlink is not a second copy, so deleting one reclaims nothing and
+ /// hashing through it would report content as duplicated with itself.
+ ///
+ ///
+ /// and are deliberately
+ /// not skipped, which a bare new EnumerationOptions() would do. Those files were scanned
+ /// before this change, and a duplicate among them is still a duplicate.
+ ///
+ ///
+ private static readonly EnumerationOptions WalkOptions = new()
+ {
+ RecurseSubdirectories = true,
+ IgnoreInaccessible = true,
+ AttributesToSkip = FileAttributes.ReparsePoint,
+ };
+
internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPath path)
{
if (!path.Exists)
@@ -19,7 +47,7 @@ internal static IReadOnlyList ScanForFiles(AbsoluteDirectoryPa
}
List files = [];
- foreach (string file in Directory.EnumerateFiles(path.WeakString, "*", SearchOption.AllDirectories))
+ foreach (string file in Directory.EnumerateFiles(path.WeakString, "*", WalkOptions))
{
files.Add(file.As());
}