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
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
75 changes: 75 additions & 0 deletions FileDeduplicator.Test/DirectoryReadBlock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.FileDeduplicator.Test;

/// <summary>
/// Makes a directory refuse to be listed for the lifetime of the block, and puts the permissions
/// back on disposal.
/// </summary>
/// <remarks>
/// The Unix arrangement is to drop read and execute on the directory, which makes
/// <see cref="Directory.EnumerateFiles(string)"/> throw <see cref="UnauthorizedAccessException"/> --
/// 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 <see cref="IsEnforced"/> reports false, the same way it does for a
/// privileged process.
/// </remarks>
internal sealed class DirectoryReadBlock : IDisposable
{
private readonly string directory;
private readonly UnixFileMode originalMode;

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

/// <summary>
/// Blocks listing of a directory, then measures whether the block took effect.
/// </summary>
/// <param name="target">The directory to protect.</param>
internal DirectoryReadBlock(string target)
{
directory = target;

if (OperatingSystem.IsWindows())
{
IsEnforced = false;
return;
}

originalMode = File.GetUnixFileMode(directory);
File.SetUnixFileMode(directory, UnixFileMode.None);
IsEnforced = ListingIsRefused();
}

/// <summary>
/// Tries the listing the block is meant to prevent, rather than guessing from the platform and
/// the user id.
/// </summary>
/// <returns><see langword="true"/> if listing the directory was refused.</returns>
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;
}
}

/// <inheritdoc />
public void Dispose()
{
if (!OperatingSystem.IsWindows())
{
File.SetUnixFileMode(directory, originalMode);
}
}
}
61 changes: 61 additions & 0 deletions FileDeduplicator.Test/FileScannerAndHasherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,67 @@ public void ScanOfAMissingDirectoryReturnsNothing()
Assert.IsEmpty(files);
}

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

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

/// <summary>
/// Identical content must hash identically regardless of the file's name or location.
/// </summary>
Expand Down
30 changes: 29 additions & 1 deletion FileDeduplicator/FileScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,34 @@ namespace ktsu.FileDeduplicator;

internal static class FileScanner
{
/// <summary>
/// How the scan walks the tree.
/// </summary>
/// <remarks>
/// Replaces <see cref="SearchOption.AllDirectories"/>, which resolves to the legacy-compatible
/// options carrying <c>IgnoreInaccessible = false</c>. Enumeration is lazy, so the
/// <see cref="UnauthorizedAccessException"/> 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.
/// <para>
/// <see cref="FileAttributes.ReparsePoint"/> 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.
/// </para>
/// <para>
/// <see cref="FileAttributes.Hidden"/> and <see cref="FileAttributes.System"/> are deliberately
/// not skipped, which a bare <c>new EnumerationOptions()</c> would do. Those files were scanned
/// before this change, and a duplicate among them is still a duplicate.
/// </para>
/// </remarks>
private static readonly EnumerationOptions WalkOptions = new()
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
AttributesToSkip = FileAttributes.ReparsePoint,
};

internal static IReadOnlyList<AbsoluteFilePath> ScanForFiles(AbsoluteDirectoryPath path)
{
if (!path.Exists)
Expand All @@ -19,7 +47,7 @@ internal static IReadOnlyList<AbsoluteFilePath> ScanForFiles(AbsoluteDirectoryPa
}

List<AbsoluteFilePath> files = [];
foreach (string file in Directory.EnumerateFiles(path.WeakString, "*", SearchOption.AllDirectories))
foreach (string file in Directory.EnumerateFiles(path.WeakString, "*", WalkOptions))
{
files.Add(file.As<AbsoluteFilePath>());
}
Expand Down
Loading