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
65 changes: 65 additions & 0 deletions FileDeduplicator.Test/FileScannerAndHasherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,71 @@
Assert.IsFalse(hashes.ContainsKey(unreadable));
}

/// <summary>
/// A file name the path type rejects but the file system accepts must be skipped, not abort the
/// scan of the whole tree (ktsu-dev/FileDeduplicator#140).
/// </summary>
[TestMethod]
public void ScanSkipsAFileWhoseNameThePathTypeRejects()
{
// Arrange
using TempTree tree = new();
AbsoluteFilePath a = tree.Write("a.txt", "x");
AbsoluteFilePath b = tree.Write("b.txt", "x");
WriteUnrepresentable(tree, "notes <draft>.txt");
WriteUnrepresentable(tree, "left|right.txt");

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

// Assert
CollectionAssert.AreEquivalent(new[] { a, b }, files.ToArray());

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEquivalent'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_FileDeduplicator&issues=AaETxAXoM-LKsyhEjQwG&open=AaETxAXoM-LKsyhEjQwG&pullRequest=173
}

/// <summary>
/// A path longer than the path type accepts must be skipped, not abort the scan of the whole
/// tree. Deep <c>node_modules</c> and build output trees routinely have such paths.
/// </summary>
[TestMethod]
public void ScanSkipsAFileWhosePathIsTooLongForThePathType()
{
// Arrange
using TempTree tree = new();
AbsoluteFilePath a = tree.Write("a.txt", "x");
AbsoluteFilePath b = tree.Write("b.txt", "x");
string deep = string.Join('/', Enumerable.Repeat(new string('d', 60), 5)) + "/long.txt";
WriteUnrepresentable(tree, deep);

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

// Assert
CollectionAssert.AreEquivalent(new[] { a, b }, files.ToArray());

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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'Assert.AreSequenceEqual' instead of 'CollectionAssert.AreEquivalent'

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_FileDeduplicator&issues=AaETxAXoM-LKsyhEjQwH&open=AaETxAXoM-LKsyhEjQwH&pullRequest=173
}

/// <summary>
/// Writes a file whose path <see cref="AbsoluteFilePath"/> rejects, which <see cref="TempTree.Write"/>
/// cannot do because it hands back that type. Marks the test inconclusive where the file system
/// refuses the path too, as Windows does for <c>&lt;</c> and <c>|</c>.
/// </summary>
/// <param name="tree">The tree to write into.</param>
/// <param name="relativePath">Path relative to the tree's root, using forward slashes.</param>
internal static void WriteUnrepresentable(TempTree tree, string relativePath)
{
string full = Path.Combine(tree.Root.WeakString, relativePath.Replace('/', Path.DirectorySeparatorChar));
try
{
_ = Directory.CreateDirectory(Path.GetDirectoryName(full)!);
File.WriteAllText(full, "unrepresentable");
}
catch (Exception ex) when (ex is IOException or ArgumentException or UnauthorizedAccessException)
{
Assert.Inconclusive($"This file system does not accept {full}, so there is nothing to stage: {ex.Message}");
}

Assert.ThrowsExactly<ArgumentException>(full.As<AbsoluteFilePath>, $"{full} is representable, so it does not stage the case under test.");
}

/// <summary>
/// Creates a named pipe with <c>mkfifo</c>, or marks the test inconclusive where there is none.
/// </summary>
Expand Down
59 changes: 59 additions & 0 deletions FileDeduplicator.Test/VerbOutputTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,63 @@ public void AnEmptyDirectoryStopsAfterDiscovery()
Assert.Contains("Found 0 file(s).", output);
Assert.DoesNotContain("Hashing files...", output);
}

/// <summary>
/// A file the path type cannot represent is named and skipped, and every verb still reports the
/// duplicates around it instead of crashing (ktsu-dev/FileDeduplicator#140).
/// </summary>
[TestMethod]
public void EveryVerbSkipsAnUnrepresentableFileAndReportsTheRest()
{
// Arrange
using TempTree tree = new();
_ = tree.Write("a.txt", "alpha");
AbsoluteFilePath copy = tree.Write("aa.txt", "alpha");
FileScannerAndHasherTests.WriteUnrepresentable(tree, "notes <draft>.txt");
string path = tree.Root.WeakString;

// Act
(string Output, int ExitCode)[] runs =
[
RunWithExitCode(new Scan { PathString = path }),
RunWithExitCode(new DryRun { PathString = path }),
RunWithExitCode(new Stats { PathString = path }),
RunWithExitCode(new Deduplicate { PathString = path }, "y"),
];

// Assert
foreach ((string output, int exitCode) in runs)
{
Assert.AreEqual(0, exitCode, output);
Assert.Contains("notes <draft>.txt", output, "The skipped file was not named.");
Assert.Contains("Duplicate", output, StringComparison.OrdinalIgnoreCase);
}

Assert.IsFalse(TempTree.Exists(copy), "Deduplicate did not remove the duplicate beside the skipped file.");
}

/// <summary>
/// A root the path type cannot represent fails in one line with exit code 1, not a stack trace.
/// </summary>
[TestMethod]
public void ScanOfAnUnrepresentableRootReportsItInOneLine()
{
// Arrange
using TempTree tree = new();
FileScannerAndHasherTests.WriteUnrepresentable(tree, "left|right/f.txt");
string root = Path.Combine(tree.Root.WeakString, "left|right");

// Act
(string output, int exitCode) = RunWithExitCode(new Scan { PathString = root });

// Assert
Assert.AreEqual(1, exitCode, output);
Assert.Contains($"Cannot scan {root}", output);
}

private static (string Output, int ExitCode) RunWithExitCode(BaseVerb verb, string stdin = "")
{
string output = ConsoleCapture.Run(verb, stdin, out int exitCode);
return (output, exitCode);
}
}
13 changes: 12 additions & 1 deletion FileDeduplicator/FileScanner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,18 @@ internal static IReadOnlyList<AbsoluteFilePath> ScanForFiles(AbsoluteDirectoryPa
continue;
}

files.Add(file.As<AbsoluteFilePath>());
// ktsu.Semantics.Paths rejects some names the file system accepts: '<', '>' and '|' are
// legal on Unix, and paths over 256 characters are routine under node_modules or deep
// build output. One such file must not abort the scan of the whole tree, so it is
// reported and skipped, the same way FileHasher handles a file it cannot read.
try
{
files.Add(file.As<AbsoluteFilePath>());
}
catch (ArgumentException ex)
{
Console.WriteLine($" Skipped {file}: {ex.Message}");
}
}

return files;
Expand Down
26 changes: 25 additions & 1 deletion FileDeduplicator/Verbs/BaseVerb.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public override int Run()
try
{
ExitCode = 0;
if (!ValidateArgs() || (ScansADirectory && !FileScanner.IsScannableDirectory(Path)))
if (!ValidateArgs() || (ScansADirectory && (!IsRepresentablePath() || !FileScanner.IsScannableDirectory(Path))))
{
return 1;
}
Expand All @@ -76,4 +76,28 @@ public override int Run()
}

internal abstract void Run(T options);

/// <summary>
/// Checks that the root given by <see cref="BaseVerb.PathString"/> converts to an
/// <see cref="AbsoluteDirectoryPath"/>, and says why in one line when it does not.
/// </summary>
/// <remarks>
/// The conversion rejects characters and lengths the file system accepts, such as <c>|</c> in a
/// Unix directory name or a path over 256 characters, and the exception would otherwise escape
/// <see cref="Run()"/> as a stack trace.
/// </remarks>
/// <returns><see langword="false"/> when the root cannot be represented.</returns>
private bool IsRepresentablePath()
{
try
{
_ = Path;
return true;
}
catch (ArgumentException ex)
{
Console.WriteLine($"Cannot scan {PathString}: {ex.Message}");
return false;
}
}
}
Loading