From 425b38fb448975aa75750f0f0f4d8d69899154dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 07:24:40 +0000 Subject: [PATCH] Stop a short imported hash crashing every Search Search printed desc.Hash[..12], and Import only rejected empty hashes, so an imported entry with a hash like "abc123" made every matching search throw ArgumentOutOfRangeException until the settings file was edited by hand. Import now skips any entry whose hash isn't 64 hex characters and says which one it skipped, and Search truncates the hash defensively for entries already stored. Fixes #151 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0113g7mUxeizhTrH6na279Ez --- ImageDescriber.Test/SearchTests.cs | 86 ++++++++++++++++++++++++++++++ ImageDescriber/ImageHasher.cs | 8 +++ ImageDescriber/Verbs/Import.cs | 11 +++- ImageDescriber/Verbs/Search.cs | 2 +- 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 ImageDescriber.Test/SearchTests.cs 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)