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
62 changes: 62 additions & 0 deletions FileDeduplicator.Test/FileScannerAndHasherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace ktsu.FileDeduplicator.Test;

using System.Diagnostics;

using ktsu.Semantics.Paths;
using ktsu.Semantics.Strings;
using Microsoft.VisualStudio.TestTools.UnitTesting;
Expand Down Expand Up @@ -127,6 +129,50 @@
Assert.Contains(real, files);
}

/// <summary>
/// A named pipe must not reach the hasher. Opening one for reading blocks until a writer
/// appears, so a single pipe anywhere in the tree hung every verb.
/// </summary>
[TestMethod]
public void ScanLeavesOutANamedPipe()
{
// Arrange
using TempTree tree = new();
AbsoluteFilePath regular = tree.Write("regular.txt", "content");
AbsoluteFilePath empty = tree.Write("nested/empty.txt", string.Empty);
MakeNamedPipe(Path.Combine(tree.Root.WeakString, "nested", "pipe"));

// Act
IReadOnlyList<AbsoluteFilePath> files = FileScanner.ScanForFiles(tree.Root);

// Assert -- regular files, including an empty one, are still found
Assert.HasCount(2, files);
Assert.Contains(regular, files);
Assert.Contains(empty, files);
}

/// <summary>
/// Scanning and hashing a tree that holds a named pipe must finish, which is what the user sees
/// of the fix. A hang fails the test after a timeout rather than stalling the run.
/// </summary>
[TestMethod]
public void ScanAndHashFinishWhenTheTreeHoldsANamedPipe()
{
// Arrange
using TempTree tree = new();
AbsoluteFilePath regular = tree.Write("a.txt", "a");
MakeNamedPipe(Path.Combine(tree.Root.WeakString, "pipe"));

// Act
Task<Dictionary<AbsoluteFilePath, string>> pass = Task.Run(() => FileHasher.HashFiles(FileScanner.ScanForFiles(tree.Root)));
bool finished = pass.Wait(TimeSpan.FromSeconds(30));

Check warning on line 168 in FileDeduplicator.Test/FileScannerAndHasherTests.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Consider using the overload that accepts a CancellationToken and pass 'TestContext.CancellationToken'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_FileDeduplicator&issues=AaDocX6JTboaTa85Fgl2&open=AaDocX6JTboaTa85Fgl2&pullRequest=148

// Assert
Assert.IsTrue(finished, "Scanning and hashing did not finish, so the named pipe was opened.");
Assert.ContainsSingle(pass.Result);
Assert.IsTrue(pass.Result.ContainsKey(regular));
}

/// <summary>
/// Identical content must hash identically regardless of the file's name or location.
/// </summary>
Expand Down Expand Up @@ -257,4 +303,20 @@
Assert.AreEqual(FileHasher.ComputeHash(alsoReadable), hashes[alsoReadable]);
Assert.IsFalse(hashes.ContainsKey(unreadable));
}

/// <summary>
/// Creates a named pipe with <c>mkfifo</c>, or marks the test inconclusive where there is none.
/// </summary>
/// <param name="path">Where to create the pipe.</param>
private static void MakeNamedPipe(string path)
{
if (OperatingSystem.IsWindows())
{
Assert.Inconclusive("Windows keeps named pipes out of the file system, so there is nothing to stage.");
}

using Process mkfifo = Process.Start("mkfifo", [path]);
mkfifo.WaitForExit();
Assert.AreEqual(0, mkfifo.ExitCode, $"mkfifo could not create {path}.");
}
}
2 changes: 2 additions & 0 deletions FileDeduplicator/FileDeduplicator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
<TargetFrameworks></TargetFrameworks>
<NoWarn>$(NoWarn);CA1812</NoWarn>
<!-- CA1812: Avoid uninstantiated internal classes - verb classes are instantiated via reflection -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- AllowUnsafeBlocks: the LibraryImport in FileType generates unsafe marshalling code -->
</PropertyGroup>

<ItemGroup>
Expand Down
11 changes: 11 additions & 0 deletions FileDeduplicator/FileScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ internal static class FileScanner
/// 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>
/// <para>
/// No attribute marks a named pipe, socket or device node, so <see cref="ScanForFiles"/> filters
/// those out itself through <see cref="FileType.IsRegularFile"/>.
/// </para>
/// </remarks>
private static readonly EnumerationOptions WalkOptions = new()
{
Expand All @@ -49,6 +53,13 @@ internal static IReadOnlyList<AbsoluteFilePath> ScanForFiles(AbsoluteDirectoryPa
List<AbsoluteFilePath> files = [];
foreach (string file in Directory.EnumerateFiles(path.WeakString, "*", WalkOptions))
{
// On Unix the enumeration also yields named pipes, sockets and device nodes. Opening a
// pipe to hash it blocks until a writer appears, and none of them is a copy of anything.
if (!FileType.IsRegularFile(file))
{
continue;
}

files.Add(file.As<AbsoluteFilePath>());
}

Expand Down
65 changes: 65 additions & 0 deletions FileDeduplicator/FileType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.FileDeduplicator;

using System.Runtime.InteropServices;

/// <summary>
/// Tells regular files apart from the other entries a Unix directory can hold.
/// </summary>
/// <remarks>
/// On Unix, <see cref="Directory.EnumerateFiles(string, string, EnumerationOptions)"/> returns every
/// entry that is not a directory, so named pipes, sockets and device nodes arrive alongside
/// regular files. .NET reports all of them with <see cref="FileAttributes.Normal"/>, and exposes no
/// managed way to read the file type. Opening a named pipe for reading blocks until something
/// opens it for writing, which hung every verb; a device node such as <c>/dev/zero</c> never
/// reaches end of file.
/// <para>
/// The type is read through <c>SystemNative_LStat</c>, the shim the runtime itself uses to
/// implement <see cref="FileInfo"/> on Unix. Unlike <c>lstat(2)</c>, whose <c>struct stat</c> layout
/// differs between operating systems and architectures, the shim fills a structure the runtime
/// defines with fixed-width fields, and normalizes the file type bits to the same values on every
/// platform. Only the leading <c>Mode</c> field is read, and the buffer is oversized, so a field
/// the runtime appends later cannot overrun it.
/// </para>
/// </remarks>
internal static partial class FileType
{
private const int TypeMask = 0xF000;
private const int RegularFile = 0x8000;

/// <summary>
/// Gets whether the entry at <paramref name="path"/> is a regular file, the only kind whose
/// content can be hashed and whose deletion reclaims space.
/// </summary>
/// <param name="path">The entry to inspect. A symlink is inspected, not followed.</param>
/// <returns>
/// <see langword="false"/> for a named pipe, socket or device node. <see langword="true"/> for a
/// regular file, on Windows, and when the entry cannot be inspected, so that hashing it reports
/// the problem as it did before.
/// </returns>
internal static bool IsRegularFile(string path)
{
if (OperatingSystem.IsWindows())
{
return true;
}

return LStat(path, out FileStatus status) != 0 || (status.Mode & TypeMask) == RegularFile;
}

[LibraryImport("libSystem.Native", EntryPoint = "SystemNative_LStat", StringMarshalling = StringMarshalling.Utf8)]
[DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
private static partial int LStat(string path, out FileStatus output);

/// <summary>
/// The runtime's <c>FileStatus</c>, of which only <c>Mode</c> is read. It follows the 32-bit
/// <c>Flags</c> field, and both have led the structure since the shim was introduced.
/// </summary>
[StructLayout(LayoutKind.Explicit, Size = 256)]
private struct FileStatus
{
[FieldOffset(4)]
internal int Mode;
}
}
Loading