diff --git a/changelog.d/unreleased/3163.fixed.md b/changelog.d/unreleased/3163.fixed.md new file mode 100644 index 0000000000..c4e9bcee13 --- /dev/null +++ b/changelog.d/unreleased/3163.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3163 +affected: + - src/CodeIndex/Cli/DiffCommandRunner.cs + - tests/CodeIndex.Tests/DiffCommandRunnerTests.cs +--- + +## English + +- **Detailed diff row output now caps large field samples (#3163)** - `cdidx diff --detailed` now truncates oversized encoded row fields with the original length and a stable SHA-256 fingerprint, so large signatures or contexts do not inflate diff reports while full-value comparison still detects drift. + +## 日本語 + +- **詳細 diff の行出力で大きなフィールドサンプルを上限付きにしました (#3163)** - `cdidx diff --detailed` は巨大な encoded row フィールドを元の長さと安定した SHA-256 fingerprint 付きで切り詰めるため、大きな signature や context が diff レポートを肥大化させず、full value 比較による drift 検出は維持されます。 diff --git a/src/CodeIndex/Cli/DiffCommandRunner.cs b/src/CodeIndex/Cli/DiffCommandRunner.cs index 36e2cf8380..51c4fa1611 100644 --- a/src/CodeIndex/Cli/DiffCommandRunner.cs +++ b/src/CodeIndex/Cli/DiffCommandRunner.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using CodeIndex.Database; using CodeIndex.Indexer; @@ -8,6 +10,7 @@ namespace CodeIndex.Cli; public static class DiffCommandRunner { private const int DefaultDiffLimit = 20; + internal const int MaxDiffEncodedFieldSampleLength = 1024; internal static int MaxDiffLimit => QueryCommandRunner.NumericFlagUpperBounds["--limit"]; private const int DriftExitCode = 1; private const int SchemaMismatchExitCode = 2; @@ -426,8 +429,8 @@ private static OrderedRowsDiff DiffOrderedRows(SqliteConnection leftConnection, var onlyInLeft = new List(limit); var onlyInRight = new List(limit); - var leftHasValue = TryReadEncodedRow(leftReader, out var leftValue); - var rightHasValue = TryReadEncodedRow(rightReader, out var rightValue); + var leftHasValue = TryReadRow(leftReader, out var leftValue); + var rightHasValue = TryReadRow(rightReader, out var rightValue); var equal = true; while (leftHasValue || rightHasValue) @@ -438,8 +441,8 @@ private static OrderedRowsDiff DiffOrderedRows(SqliteConnection leftConnection, if (comparison == 0) { - leftHasValue = TryReadEncodedRow(leftReader, out leftValue); - rightHasValue = TryReadEncodedRow(rightReader, out rightValue); + leftHasValue = TryReadRow(leftReader, out leftValue); + rightHasValue = TryReadRow(rightReader, out rightValue); continue; } @@ -447,14 +450,14 @@ private static OrderedRowsDiff DiffOrderedRows(SqliteConnection leftConnection, if (comparison < 0) { if (onlyInLeft.Count < limit) - onlyInLeft.Add(leftValue.Encoded); - leftHasValue = TryReadEncodedRow(leftReader, out leftValue); + onlyInLeft.Add(EncodeRow(leftValue.SortValues)); + leftHasValue = TryReadRow(leftReader, out leftValue); } else { if (onlyInRight.Count < limit) - onlyInRight.Add(rightValue.Encoded); - rightHasValue = TryReadEncodedRow(rightReader, out rightValue); + onlyInRight.Add(EncodeRow(rightValue.SortValues)); + rightHasValue = TryReadRow(rightReader, out rightValue); } if (onlyInLeft.Count >= limit && onlyInRight.Count >= limit) @@ -525,14 +528,14 @@ private static bool RowsEqual(SqliteConnection leftConnection, SqliteConnection using var leftReader = leftCommand.ExecuteReader(); using var rightReader = rightCommand.ExecuteReader(); - var leftHasValue = TryReadEncodedRow(leftReader, out var leftValue); - var rightHasValue = TryReadEncodedRow(rightReader, out var rightValue); + var leftHasValue = TryReadRow(leftReader, out var leftValue); + var rightHasValue = TryReadRow(rightReader, out var rightValue); while (leftHasValue && rightHasValue) { - if (!string.Equals(leftValue.Encoded, rightValue.Encoded, StringComparison.Ordinal)) + if (CompareRows(leftValue, rightValue) != 0) return false; - leftHasValue = TryReadEncodedRow(leftReader, out leftValue); - rightHasValue = TryReadEncodedRow(rightReader, out rightValue); + leftHasValue = TryReadRow(leftReader, out leftValue); + rightHasValue = TryReadRow(rightReader, out rightValue); } return leftHasValue == rightHasValue; @@ -560,11 +563,11 @@ private static bool StringRowsEqual(SqliteConnection leftConnection, SqliteConne return leftHasValue == rightHasValue; } - private static bool TryReadEncodedRow(SqliteDataReader reader, out EncodedDiffRow value) + private static bool TryReadRow(SqliteDataReader reader, out DiffRow value) { if (!reader.Read()) { - value = EncodedDiffRow.Empty; + value = DiffRow.Empty; return false; } @@ -572,11 +575,11 @@ private static bool TryReadEncodedRow(SqliteDataReader reader, out EncodedDiffRo for (var i = 0; i < reader.FieldCount; i++) sortValues[i] = reader.IsDBNull(i) ? null : reader.GetValue(i); - value = new EncodedDiffRow(EncodeRow(reader), sortValues); + value = new DiffRow(sortValues); return true; } - private static int CompareRows(EncodedDiffRow left, EncodedDiffRow right) + private static int CompareRows(DiffRow left, DiffRow right) { var count = Math.Min(left.SortValues.Length, right.SortValues.Length); for (var i = 0; i < count; i++) @@ -698,24 +701,41 @@ private static long ExecuteCountIfTableExists(SqliteConnection connection, strin return Convert.ToInt64(command.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture); } - private static string EncodeRow(SqliteDataReader reader) + private static string EncodeRow(object?[] values) { - var fields = new string[reader.FieldCount]; - for (var i = 0; i < reader.FieldCount; i++) + var fields = new string[values.Length]; + for (var i = 0; i < values.Length; i++) { - if (reader.IsDBNull(i)) + var rawValue = values[i]; + if (rawValue is null or DBNull) { fields[i] = "-1:"; continue; } - var value = Convert.ToString(reader.GetValue(i), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; - fields[i] = value.Length.ToString(System.Globalization.CultureInfo.InvariantCulture) + ":" + value; + var value = Convert.ToString(rawValue, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty; + var encodedValue = EncodeFieldValue(value); + fields[i] = encodedValue.Length.ToString(System.Globalization.CultureInfo.InvariantCulture) + ":" + encodedValue; } return string.Join("|", fields); } + private static string EncodeFieldValue(string value) + { + if (value.Length <= MaxDiffEncodedFieldSampleLength) + return value; + + var sample = value[..MaxDiffEncodedFieldSampleLength]; + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + return sample + + "...[truncated original_length=" + + value.Length.ToString(System.Globalization.CultureInfo.InvariantCulture) + + " sha256=" + + hash + + "]"; + } + private static void WriteJson(DiffJsonResult result, JsonSerializerOptions jsonOptions) { Console.WriteLine(JsonSerializer.Serialize( @@ -791,11 +811,10 @@ private sealed record OrderedRowsDiff( List OnlyInLeft, List OnlyInRight); - private sealed record EncodedDiffRow( - string Encoded, + private sealed record DiffRow( object?[] SortValues) { - public static readonly EncodedDiffRow Empty = new(string.Empty, []); + public static readonly DiffRow Empty = new([]); } private sealed record DiffDbHeader( diff --git a/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs b/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs index 05661eedb5..99cc195afb 100644 --- a/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DiffCommandRunnerTests.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using CodeIndex.Cli; using CodeIndex.Database; @@ -179,6 +181,71 @@ public void Run_DetailedJsonReportsLimitedSymbolRows_Issue2885() } } + [Fact] + public void Run_DetailedJsonTruncatesLargeEncodedSymbolFields_Issue3163() + { + var leftRoot = TestProjectHelper.CreateTempProject("cdidx_diff_large_field_left"); + var rightRoot = TestProjectHelper.CreateTempProject("cdidx_diff_large_field_right"); + try + { + var leftDb = TestProjectHelper.CreateProjectDb(leftRoot); + var rightDb = TestProjectHelper.CreateProjectDb(rightRoot); + TestProjectHelper.InsertIndexedFile(leftDb, "src/Same.cs", "csharp", "public class Same { }"); + TestProjectHelper.InsertIndexedFile(rightDb, "src/Same.cs", "csharp", "public class Same { }"); + + var longSignature = new string('a', DiffCommandRunner.MaxDiffEncodedFieldSampleLength * 4); + InsertSyntheticMethodSymbol(leftDb, "src/Same.cs", "Drifted", longSignature); + + var (exitCode, output) = RunWithCapturedOut([leftDb, rightDb, "--json", "--detailed", "--limit", "1"]); + + Assert.Equal(1, exitCode); + using var document = JsonDocument.Parse(output); + var row = Assert.Single(document.RootElement.GetProperty("symbols_only_in_left").EnumerateArray()).GetString(); + Assert.NotNull(row); + var expectedHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(longSignature))).ToLowerInvariant(); + Assert.Contains(new string('a', DiffCommandRunner.MaxDiffEncodedFieldSampleLength), row, StringComparison.Ordinal); + Assert.Contains($"[truncated original_length={longSignature.Length}", row, StringComparison.Ordinal); + Assert.Contains($"sha256={expectedHash}", row, StringComparison.Ordinal); + Assert.DoesNotContain(new string('a', DiffCommandRunner.MaxDiffEncodedFieldSampleLength + 1), row, StringComparison.Ordinal); + Assert.True(row!.Length < longSignature.Length); + } + finally + { + TestProjectHelper.DeleteDirectory(leftRoot); + TestProjectHelper.DeleteDirectory(rightRoot); + } + } + + [Fact] + public void Run_SummaryOnlyDetectsLargeRowDriftAfterSharedDisplayPrefix_Issue3163() + { + var leftRoot = TestProjectHelper.CreateTempProject("cdidx_diff_large_prefix_left"); + var rightRoot = TestProjectHelper.CreateTempProject("cdidx_diff_large_prefix_right"); + try + { + var leftDb = TestProjectHelper.CreateProjectDb(leftRoot); + var rightDb = TestProjectHelper.CreateProjectDb(rightRoot); + TestProjectHelper.InsertIndexedFile(leftDb, "src/Same.cs", "csharp", "public class Same { public void Run() { } }"); + TestProjectHelper.InsertIndexedFile(rightDb, "src/Same.cs", "csharp", "public class Same { public void Run() { } }"); + + var sharedPrefix = new string('x', DiffCommandRunner.MaxDiffEncodedFieldSampleLength); + UpdateFirstChunkContent(leftDb, sharedPrefix + "left"); + UpdateFirstChunkContent(rightDb, sharedPrefix + "right"); + + var (exitCode, output) = RunWithCapturedOut([leftDb, rightDb, "--summary-only"]); + + Assert.Equal(1, exitCode); + using var document = JsonDocument.Parse(output); + Assert.Equal("different", document.RootElement.GetProperty("status").GetString()); + Assert.False(document.RootElement.GetProperty("identical").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(leftRoot); + TestProjectHelper.DeleteDirectory(rightRoot); + } + } + [Fact] public void Run_DetailedJsonUsesSqlOrderForStreamingSymbolDiff_Issue2885() {