From cdcd834e5171debcc04046fd9459538dcc9800cb Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 23:14:56 +0900 Subject: [PATCH] Fix checksum hex case drift (#1722) --- DEVELOPER_GUIDE.md | 4 ++ changelog.d/unreleased/1722.fixed.md | 17 ++++++++ src/CodeIndex/Cli/DbPathResolver.cs | 2 +- src/CodeIndex/Database/NameFold.cs | 2 +- tests/CodeIndex.Tests/DbPathResolverTests.cs | 43 ++++++++++++++++++++ tests/CodeIndex.Tests/FileIndexerTests.cs | 9 ++++ tests/CodeIndex.Tests/NameFoldTests.cs | 9 ++++ 7 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/1722.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 8870f326db..f69b39d588 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -177,6 +177,10 @@ After a successful `cdidx index` run, the writer refreshes SQLite planner statis ## Database schema +Persisted SHA-256 hashes are lowercase hexadecimal strings. New hash emitters +must format bytes with lowercase hex and comparisons must use ordinal +case-sensitive equality so format drift is visible instead of silently accepted. + ### Tables ```sql diff --git a/changelog.d/unreleased/1722.fixed.md b/changelog.d/unreleased/1722.fixed.md new file mode 100644 index 0000000000..cdb0bd5ce2 --- /dev/null +++ b/changelog.d/unreleased/1722.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 1722 +affected: + - src/CodeIndex/Cli/DbPathResolver.cs + - src/CodeIndex/Database/NameFold.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Checksum comparisons now enforce lowercase hex format (#1722)** — persisted SHA-256 hashes are compared case-sensitively and the fold fingerprint emitter now uses lowercase hex, making hash format drift visible. + +## 日本語 + +- **checksum 比較で lowercase hex 形式を厳格に扱うようになりました (#1722)** — 永続化された SHA-256 ハッシュを大文字小文字を区別して比較し、fold fingerprint の出力も lowercase hex に統一したため、ハッシュ形式のずれを検知できるようになりました。 diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index 38f1f85620..6419186748 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -439,7 +439,7 @@ private static SampleMatchResult CountMatchingSamples(string candidateRoot, IRea // FileIndexer のヘルパを使い、OS をまたいだ clone (CRLF と LF) でも、 // 他 OS で生成された checksum と引き続き一致するようにする。 var checksum = FileIndexer.ComputeChecksum(File.ReadAllBytes(ioPath)); - if (string.Equals(checksum, sample.Checksum, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(checksum, sample.Checksum, StringComparison.Ordinal)) checksumMatches++; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) diff --git a/src/CodeIndex/Database/NameFold.cs b/src/CodeIndex/Database/NameFold.cs index 807ffd1a84..06bbab0899 100644 --- a/src/CodeIndex/Database/NameFold.cs +++ b/src/CodeIndex/Database/NameFold.cs @@ -80,7 +80,7 @@ public static string Fingerprint() } var hash = SHA256.HashData(Encoding.UTF8.GetBytes(buffer.ToString())); - return Convert.ToHexString(hash[..8]); + return Convert.ToHexString(hash[..8]).ToLowerInvariant(); } diff --git a/tests/CodeIndex.Tests/DbPathResolverTests.cs b/tests/CodeIndex.Tests/DbPathResolverTests.cs index 48817e7ff1..44ab2676ee 100644 --- a/tests/CodeIndex.Tests/DbPathResolverTests.cs +++ b/tests/CodeIndex.Tests/DbPathResolverTests.cs @@ -248,6 +248,49 @@ public void ResolveProjectRootForQuery_ExplicitProjectLocalDbPrefersCdidxSibling } } + [Fact] + public void ResolveProjectRootForQuery_ExplicitProjectLocalDbDoesNotCaseFoldPersistedChecksums() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_upper_checksum"); + var staleRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_upper_checksum_stale"); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + Directory.CreateDirectory(Path.Combine(staleRoot, "src")); + + const string indexedContent = "class App {}\n"; + const string staleContent = "class App { void Different() {} }\n"; + File.WriteAllText(Path.Combine(projectRoot, "src", "app.cs"), indexedContent); + File.WriteAllText(Path.Combine(staleRoot, "src", "app.cs"), staleContent); + + using (var db = new DbContext(dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.IndexedProjectRootMetaKey, staleRoot); + } + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", indexedContent); + using (var db = new DbContext(dbPath)) + { + using var cmd = db.Connection.CreateCommand(); + cmd.CommandText = "UPDATE files SET checksum = upper(checksum) WHERE path = @path"; + cmd.Parameters.AddWithValue("@path", "src/app.cs"); + cmd.ExecuteNonQuery(); + } + + var resolved = DbPathResolver.ResolveProjectRootForQuery(dbPath, dbPathExplicit: true); + + Assert.Equal(staleRoot, resolved); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(staleRoot); + } + } + [Fact] public void ResolveProjectRootForQuery_ExplicitProjectLocalReadOnlyUriWithoutMetadataReturnsNull() { diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index d95454b002..fafab7443b 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -3063,6 +3063,15 @@ public void ComputeChecksum_LongInputWithoutCr_MatchesRawByteSha256() Assert.Equal(expected, FileIndexer.ComputeChecksum(payload)); } + [Fact] + public void ComputeChecksum_ReturnsLowercaseHex() + { + var checksum = FileIndexer.ComputeChecksum(System.Text.Encoding.UTF8.GetBytes("ABC\n")); + + Assert.Equal(checksum.ToLowerInvariant(), checksum); + Assert.DoesNotContain(checksum, c => c is >= 'A' and <= 'F'); + } + [Fact] public void BuildRecord_BomOnlyFile_ReportsOnePhysicalLine() { diff --git a/tests/CodeIndex.Tests/NameFoldTests.cs b/tests/CodeIndex.Tests/NameFoldTests.cs index d985451693..ce50f712ee 100644 --- a/tests/CodeIndex.Tests/NameFoldTests.cs +++ b/tests/CodeIndex.Tests/NameFoldTests.cs @@ -19,4 +19,13 @@ public void Fold_RemainsLocaleInvariantForTurkishDottedI() Assert.Equal("i", NameFold.Fold("i")); Assert.NotEqual(NameFold.Fold("İ"), NameFold.Fold("i")); } + + [Fact] + public void Fingerprint_ReturnsLowercaseHex() + { + var fingerprint = NameFold.Fingerprint(); + + Assert.Equal(fingerprint.ToLowerInvariant(), fingerprint); + Assert.DoesNotContain(fingerprint, c => c is >= 'A' and <= 'F'); + } }