diff --git a/ImageDescriber.Test/SearchTests.cs b/ImageDescriber.Test/SearchTests.cs new file mode 100644 index 0000000..64b82b8 --- /dev/null +++ b/ImageDescriber.Test/SearchTests.cs @@ -0,0 +1,86 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImageDescriber.Tests; + +using ktsu.ImageDescriber.Verbs; +using ktsu.Semantics.Paths; +using ktsu.Semantics.Strings; + +[TestClass] +public class SearchTests +{ + private PersistentState? originalSettings; + + [TestInitialize] + public void Initialize() => originalSettings = Program.Settings; + + [TestCleanup] + public void Cleanup() => Program.Settings = originalSettings!; + + [TestMethod] + public void SearchPrintsEntryWhoseHashIsShorterThanTwelveCharacters() + { + Program.Settings = new PersistentState(); + Program.Settings.Descriptions["abc123"] = new ImageDescription + { + Hash = "abc123", + Description = "a dog on a beach", + SuggestedFileName = "dog.jpg".As(), + }; + + string output = CaptureConsole(() => new Search().Run(new Search { Query = "dog" })); + + StringAssert.Contains(output, "Hash: abc123..."); + StringAssert.Contains(output, "Description: a dog on a beach"); + } + + [TestMethod] + public void MergeEntriesSkipsEntriesWhoseHashIsNotSha256Hex() + { + Program.Settings = new PersistentState(); + string validHash = new('a', ImageHasher.HashLength); + List entries = + [ + new() { Hash = "abc123" }, + new() { Hash = new string('z', ImageHasher.HashLength) }, + new() { Hash = validHash }, + ]; + + (int NewCount, int UpdatedCount, int SkippedCount) result = default; + CaptureConsole(() => result = Import.MergeEntries(entries)); + + Assert.AreEqual(1, result.NewCount); + Assert.AreEqual(2, result.SkippedCount); + string[] expectedKeys = [validHash]; + CollectionAssert.AreEquivalent(expectedKeys, Program.Settings.Descriptions.Keys.ToArray()); + } + + [TestMethod] + public void IsValidHashAcceptsOnlySha256HexStrings() + { + Assert.IsTrue(ImageHasher.IsValidHash(new string('0', 64))); + Assert.IsTrue(ImageHasher.IsValidHash(string.Concat(Enumerable.Repeat("0123456789abcdefABCDEF", 3))[..64])); + Assert.IsFalse(ImageHasher.IsValidHash(null)); + Assert.IsFalse(ImageHasher.IsValidHash(string.Empty)); + Assert.IsFalse(ImageHasher.IsValidHash("abc123")); + Assert.IsFalse(ImageHasher.IsValidHash(new string('0', 65))); + Assert.IsFalse(ImageHasher.IsValidHash(new string('g', 64))); + } + + private static string CaptureConsole(Action action) + { + TextWriter original = Console.Out; + using StringWriter writer = new(); + Console.SetOut(writer); + try + { + action(); + } + finally + { + Console.SetOut(original); + } + + return writer.ToString(); + } +} diff --git a/ImageDescriber/ImageHasher.cs b/ImageDescriber/ImageHasher.cs index c0779d5..016f4b2 100644 --- a/ImageDescriber/ImageHasher.cs +++ b/ImageDescriber/ImageHasher.cs @@ -5,6 +5,7 @@ namespace ktsu.ImageDescriber; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Security.Cryptography; using ktsu.Semantics.Paths; @@ -31,6 +32,13 @@ internal static Dictionary HashFiles(IReadOnlyList(results); } + internal const int HashLength = 64; + + internal static bool IsValidHash(string? hash) => + hash is { Length: HashLength } && hash.All(char.IsAsciiHexDigit); + + internal static string ShortHash(string hash) => hash[..Math.Min(12, hash.Length)]; + internal static string ComputeHash(AbsoluteFilePath filePath) { using FileStream stream = File.OpenRead(filePath.WeakString); diff --git a/ImageDescriber/Verbs/Import.cs b/ImageDescriber/Verbs/Import.cs index 9ad48e6..281636e 100644 --- a/ImageDescriber/Verbs/Import.cs +++ b/ImageDescriber/Verbs/Import.cs @@ -85,7 +85,7 @@ internal override void Run(Import options) } } - private static (int NewCount, int UpdatedCount, int SkippedCount) MergeEntries(List entries) + internal static (int NewCount, int UpdatedCount, int SkippedCount) MergeEntries(List entries) { int newCount = 0; int updatedCount = 0; @@ -99,6 +99,15 @@ private static (int NewCount, int UpdatedCount, int SkippedCount) MergeEntries(L continue; } + // Every stored hash is a SHA-256 hex string, and Search and Scan both slice it, so a + // malformed one would crash them on every later run rather than just this import. + if (!ImageHasher.IsValidHash(entry.Hash)) + { + Console.WriteLine($" Skipping entry with invalid hash \"{entry.Hash}\" ({entry.SuggestedFileName}): expected {ImageHasher.HashLength} hex characters."); + skippedCount++; + continue; + } + if (Program.Settings.Descriptions.TryGetValue(entry.Hash, out ImageDescription? existing)) { if (MergeKnownPaths(entry, existing)) diff --git a/ImageDescriber/Verbs/Search.cs b/ImageDescriber/Verbs/Search.cs index 794d524..3a6c66c 100644 --- a/ImageDescriber/Verbs/Search.cs +++ b/ImageDescriber/Verbs/Search.cs @@ -37,7 +37,7 @@ internal override void Run(Search options) foreach (ImageDescription desc in matches) { Console.WriteLine($" Suggested: {desc.SuggestedFileName}"); - Console.WriteLine($" Hash: {desc.Hash[..12]}..."); + Console.WriteLine($" Hash: {ImageHasher.ShortHash(desc.Hash)}..."); Console.WriteLine($" Date: {desc.DescribedAt:yyyy-MM-dd HH:mm:ss} UTC"); Console.WriteLine($" Paths ({desc.KnownPaths.Count}):"); foreach (AbsoluteFilePath path in desc.KnownPaths)