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
4 changes: 4 additions & 0 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/1722.fixed.md
Original file line number Diff line number Diff line change
@@ -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 に統一したため、ハッシュ形式のずれを検知できるようになりました。
2 changes: 1 addition & 1 deletion src/CodeIndex/Cli/DbPathResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/CodeIndex/Database/NameFold.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}


Expand Down
43 changes: 43 additions & 0 deletions tests/CodeIndex.Tests/DbPathResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
9 changes: 9 additions & 0 deletions tests/CodeIndex.Tests/FileIndexerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3176,6 +3176,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()
{
Expand Down
9 changes: 9 additions & 0 deletions tests/CodeIndex.Tests/NameFoldTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Loading