From f933217066fa7d1f6473f3c547e3c668d3d401c2 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:45:47 +0900 Subject: [PATCH 1/9] Bound config and workspace manifest reads (#2844) --- changelog.d/unreleased/2844.security.md | 19 ++++++++++++++++++ src/CodeIndex/Cli/CdidxConfigFile.cs | 4 +++- src/CodeIndex/Cli/WorkspaceManifest.cs | 5 ++++- tests/CodeIndex.Tests/CdidxConfigFileTests.cs | 20 +++++++++++++++++++ .../WorkspaceCommandRunnerTests.cs | 19 ++++++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/2844.security.md diff --git a/changelog.d/unreleased/2844.security.md b/changelog.d/unreleased/2844.security.md new file mode 100644 index 0000000000..95d068e958 --- /dev/null +++ b/changelog.d/unreleased/2844.security.md @@ -0,0 +1,19 @@ +--- +category: security +issues: + - 2844 +affected: + - src/CodeIndex/Cli/CdidxConfigFile.cs + - src/CodeIndex/Cli/DataDirectorySecurity.cs + - src/CodeIndex/Cli/WorkspaceManifest.cs + - tests/CodeIndex.Tests/CdidxConfigFileTests.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +--- + +## English + +- **CLI config and workspace manifest reads are now size-limited (#2844)** — `.cdidxrc.json` and workspace manifest loading now reject oversized JSON before deserialization instead of reading unbounded repository-controlled files. + +## 日本語 + +- **CLI config と workspace manifest の読み込みにサイズ上限を設けました (#2844)** — `.cdidxrc.json` と workspace manifest の読み込みは、リポジトリ管理下の巨大 JSON を無制限に読むのではなく、deserialize 前に oversized file を拒否します。 diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index 4dfce1bfa5..3f6028a885 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -26,6 +26,7 @@ internal static class CdidxConfigFile internal static readonly string ProjectConfigRelativePath = Path.Combine(".cdidx", "config.json"); internal const string DisableEnvVar = "CDIDX_DISABLE_CONFIG_FILE"; internal const string ConfigSourceEnvironmentVariablePrefix = "CDIDX_CONFIG_SOURCE__"; + internal const int MaxConfigFileBytes = 64 * 1024; private static readonly IReadOnlyList KnownTopLevelKeys = new[] { @@ -85,7 +86,8 @@ internal static LoadResult LoadAndApply( string text; try { - text = File.ReadAllText(path); + text = DataDirectorySecurity.ReadTextWithinLimit(path, MaxConfigFileBytes) + ?? throw new InvalidDataException($"{FileName} exceeds the {MaxConfigFileBytes} byte limit."); } catch (Exception ex) { diff --git a/src/CodeIndex/Cli/WorkspaceManifest.cs b/src/CodeIndex/Cli/WorkspaceManifest.cs index 569f91744d..8d2447c28b 100644 --- a/src/CodeIndex/Cli/WorkspaceManifest.cs +++ b/src/CodeIndex/Cli/WorkspaceManifest.cs @@ -26,6 +26,7 @@ internal static class WorkspaceManifestLoader { internal const string FileName = "cdidx.workspace.json"; internal const string DotFileName = ".cdidx-workspace.json"; + internal const int MaxManifestBytes = 64 * 1024; internal static WorkspaceManifest? Find(string startingDirectory) { @@ -58,7 +59,9 @@ internal static WorkspaceManifest Load(string path) { var fullPath = Path.GetFullPath(path); var root = Path.GetDirectoryName(fullPath) ?? Environment.CurrentDirectory; - using var document = JsonDocument.Parse(File.ReadAllText(fullPath), new JsonDocumentOptions + var text = DataDirectorySecurity.ReadTextWithinLimit(fullPath, MaxManifestBytes) + ?? throw new InvalidDataException($"{fullPath} exceeds the {MaxManifestBytes} byte limit."); + using var document = JsonDocument.Parse(text, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index c721dc8421..c9260228fa 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -371,6 +371,26 @@ public void Run_MalformedConfigFile_FailsWithUsageError() finally { TestProjectHelper.DeleteDirectory(dir); } } + [Fact] + public void LoadAndApply_OversizedConfigFile_FailsBeforeParsing() + { + var dir = CreateTempDir(); + try + { + File.WriteAllText( + Path.Combine(dir, ".cdidxrc.json"), + new string('x', CdidxConfigFile.MaxConfigFileBytes + 1)); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains($"{CdidxConfigFile.MaxConfigFileBytes} byte limit", result.Error); + Assert.Empty(env.Writes); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + private static string CreateTempDir() { var path = Path.Combine(Path.GetTempPath(), $"cdidx_config_{Guid.NewGuid():N}"); diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 1a0014f3af..b100d65f47 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -44,6 +44,25 @@ public void WorkspaceList_ReadsManifestMembers() } } + [Fact] + public void WorkspaceManifestLoader_Load_RejectsOversizedManifest() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_oversized"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + File.WriteAllText(manifestPath, new string('x', WorkspaceManifestLoader.MaxManifestBytes + 1)); + + var ex = Assert.Throws(() => WorkspaceManifestLoader.Load(manifestPath)); + + Assert.Contains($"{WorkspaceManifestLoader.MaxManifestBytes} byte limit", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceErrors_HonorJsonFlag() { From 3e296f1a18d0b9390bf6f022dab2969efc33ba7b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:46:33 +0900 Subject: [PATCH 2/9] Bound scan checkpoint reads (#2856) --- changelog.d/unreleased/2856.security.md | 16 +++++++ .../Cli/IndexCommandRunner.FullScan.cs | 8 +++- .../IndexCommandRunnerTests.cs | 44 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/2856.security.md diff --git a/changelog.d/unreleased/2856.security.md b/changelog.d/unreleased/2856.security.md new file mode 100644 index 0000000000..f266ef2df6 --- /dev/null +++ b/changelog.d/unreleased/2856.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 2856 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +--- + +## English + +- **Full-scan checkpoint loading now has a byte cap (#2856)** — `.cdidx/scan-checkpoint.json` is ignored when it exceeds the configured limit, preventing unbounded reads before checkpoint deserialization. + +## 日本語 + +- **full-scan checkpoint の読み込みに byte 上限を設けました (#2856)** — `.cdidx/scan-checkpoint.json` が上限を超える場合は無視し、checkpoint deserialize 前の無制限読み込みを防ぎます。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 07ea27cbd4..32a02f9eae 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -285,6 +285,8 @@ ex is UnauthorizedAccessException || ex is IOException || ex is SqliteException { SqliteErrorCode: 5 or 6 or 8 or 10 or 14 }; + internal const int MaxScanCheckpointBytes = 1024 * 1024; + private static IReadOnlySet LoadScanCheckpoint(string path, string? currentHead) { try @@ -292,7 +294,11 @@ private static IReadOnlySet LoadScanCheckpoint(string path, string? curr if (string.IsNullOrWhiteSpace(currentHead) || !File.Exists(path)) return new HashSet(StringComparer.Ordinal); - var checkpoint = JsonSerializer.Deserialize(File.ReadAllText(path)); + var text = DataDirectorySecurity.ReadTextWithinLimit(path, MaxScanCheckpointBytes, FileShare.ReadWrite); + if (text is null) + return new HashSet(StringComparer.Ordinal); + + var checkpoint = JsonSerializer.Deserialize(text); if (checkpoint is not { Version: ScanCheckpointVersion } || !string.Equals(checkpoint.GitHead, currentHead, StringComparison.Ordinal) || checkpoint.Directories is not { Count: > 0 }) diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index ecae312ca3..b3e771f66a 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -5715,6 +5715,50 @@ public void Run_FullScan_WritesCheckpointAndUsesItOnSuccessfulRetry() } } + [Fact] + public void Run_FullScan_IgnoresOversizedCheckpoint() + { + var projectRoot = CreateTempProject(); + try + { + RunGit(projectRoot, "init"); + RunGit(projectRoot, "config", "user.email", "test@example.com"); + RunGit(projectRoot, "config", "user.name", "Test"); + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + File.WriteAllText(Path.Combine(projectRoot, "src", "a.cs"), "public class A { }\n"); + RunGit(projectRoot, "add", "."); + RunGit(projectRoot, "commit", "-m", "initial"); + var head = RunGitCaptureStdOut(projectRoot, "rev-parse", "HEAD").Trim(); + + var checkpointPath = Path.Combine(projectRoot, ".cdidx", "scan-checkpoint.json"); + Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!); + var checkpoint = $$""" + { + "Version": 1, + "GitHead": "{{head}}", + "Directories": [ + "src" + ] + } + """; + var padding = new System.Text.StringBuilder(IndexCommandRunner.MaxScanCheckpointBytes + 2048); + while (checkpoint.Length + padding.Length <= IndexCommandRunner.MaxScanCheckpointBytes) + padding.Append(' ', 1024).Append('\n'); + File.WriteAllText(checkpointPath, checkpoint + padding); + + var (exitCode, _) = RunAndCaptureJson([projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + + var indexedPaths = ReadIndexedPaths(Path.Combine(projectRoot, ".cdidx", "codeindex.db")); + Assert.Contains("src/a.cs", indexedPaths); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_FullScan_PurgesStaleRowsWithinListedDirectoriesEvenWhenAnotherDirectoryIsUnreadable() { From 70e8d85010d82a4e26342ccfd832fdd2675af528 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:46:56 +0900 Subject: [PATCH 3/9] Bound suggestion store reads (#2857) --- changelog.d/unreleased/2857.security.md | 16 ++++++++++++ src/CodeIndex/Cli/DataDirectorySecurity.cs | 15 +++++++---- src/CodeIndex/Cli/SuggestionStore.cs | 17 ++++++++++-- tests/CodeIndex.Tests/SuggestionStoreTests.cs | 26 +++++++++++++++++++ 4 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/2857.security.md diff --git a/changelog.d/unreleased/2857.security.md b/changelog.d/unreleased/2857.security.md new file mode 100644 index 0000000000..c4276d6e62 --- /dev/null +++ b/changelog.d/unreleased/2857.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 2857 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- **Suggestion store reads are now bounded before JSON parsing (#2857)** — `suggestions.json` loading and filtered status reads now stop at the store size limit and preserve the corrupt-backup behavior instead of allocating for unbounded files. + +## 日本語 + +- **Suggestion store の JSON parse 前読み込みに上限を設けました (#2857)** — `suggestions.json` の全件読み込みと status filter 読み込みは store size limit で停止し、無制限ファイルのために確保するのではなく既存の corrupt backup 挙動を維持します。 diff --git a/src/CodeIndex/Cli/DataDirectorySecurity.cs b/src/CodeIndex/Cli/DataDirectorySecurity.cs index 02ff5922a2..a13a5d30a1 100644 --- a/src/CodeIndex/Cli/DataDirectorySecurity.cs +++ b/src/CodeIndex/Cli/DataDirectorySecurity.cs @@ -56,7 +56,7 @@ public static void WritePrivateText(string path, string contents, Encoding? enco AtomicFileWriter.WriteText(path, contents, outputEncoding, ApplyPrivateFileMode); } - public static string? ReadTextWithinLimit(string path, int maxBytes, FileShare share = FileShare.Read) + public static byte[]? ReadBytesWithinLimit(string path, int maxBytes, FileShare share = FileShare.Read) { if (maxBytes <= 0) throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "Maximum byte count must be positive."); @@ -64,12 +64,11 @@ public static void WritePrivateText(string path, string contents, Encoding? enco var ioPath = LongPath.EnsureWindowsPrefix(path); using var stream = File.Open(ioPath, FileMode.Open, FileAccess.Read, share); using var output = new MemoryStream(capacity: Math.Min(maxBytes, 8192)); - var buffer = new byte[Math.Min(maxBytes + 1, 8192)]; + var buffer = new byte[Math.Min(maxBytes, 8192)]; var total = 0; while (true) { - var remaining = maxBytes + 1 - total; - var read = stream.Read(buffer, 0, Math.Min(buffer.Length, remaining)); + var read = stream.Read(buffer, 0, buffer.Length); if (read == 0) break; @@ -80,7 +79,13 @@ public static void WritePrivateText(string path, string contents, Encoding? enco output.Write(buffer, 0, read); } - return Encoding.UTF8.GetString(output.ToArray()); + return output.ToArray(); + } + + public static string? ReadTextWithinLimit(string path, int maxBytes, FileShare share = FileShare.Read) + { + var bytes = ReadBytesWithinLimit(path, maxBytes, share); + return bytes is null ? null : Encoding.UTF8.GetString(bytes); } public static string? GetUnixModeString(string? path) diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index f1ee64d99b..01b32d030d 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -32,6 +32,7 @@ public class SuggestionStore internal const double DefaultDedupThreshold = 0.85; internal const int DefaultMaxAgeDays = 365; internal const int DefaultMaxCount = 5000; + internal const int MaxSuggestionStoreBytes = 8 * 1024 * 1024; private const int FuzzyDedupRecentLimit = 100; private const string RedactedAwsAccessKey = "[REDACTED:aws_access_key]"; private const string RedactedBearerToken = "[REDACTED:bearer_token]"; @@ -439,7 +440,13 @@ private List ReadUnlocked() return new List(); } - var json = File.ReadAllText(ioPath); + var json = DataDirectorySecurity.ReadTextWithinLimit(ioPath, MaxSuggestionStoreBytes, StreamingReadFileShare); + if (json is null) + { + PreserveCorruptFile(); + return new List(); + } + if (string.IsNullOrWhiteSpace(json)) return new List(); @@ -622,7 +629,13 @@ private List ReadFilteredUnlocked( if (!File.Exists(ioPath)) return new List(); - var snapshot = File.ReadAllBytes(ioPath); + var snapshot = DataDirectorySecurity.ReadBytesWithinLimit(ioPath, MaxSuggestionStoreBytes, StreamingReadFileShare); + if (snapshot is null) + { + PreserveCorruptFile(); + return new List(); + } + if (snapshot.Length == 0) { PreserveCorruptFile(); diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 2ae6760e19..0512e9e46e 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -81,6 +81,32 @@ public void ComputeHash_DifferentCategory_ReturnsDifferentHash() Assert.NotEqual(hash1, hash2); } + [Fact] + public void LoadAll_OversizedStore_PreservesBackupAndReturnsEmpty() + { + var path = Path.Combine(_tempDir, "suggestions-codeindex.json"); + File.WriteAllBytes(path, Enumerable.Repeat((byte)'x', SuggestionStore.MaxSuggestionStoreBytes + 1).ToArray()); + + var records = _store.LoadAll(); + + Assert.Empty(records); + Assert.False(File.Exists(path)); + Assert.True(File.Exists(path + ".bak")); + } + + [Fact] + public void LoadByStatus_OversizedStore_PreservesBackupAndReturnsEmpty() + { + var path = Path.Combine(_tempDir, "suggestions-codeindex.json"); + File.WriteAllBytes(path, Enumerable.Repeat((byte)'x', SuggestionStore.MaxSuggestionStoreBytes + 1).ToArray()); + + var records = _store.LoadByStatus(SuggestionStatus.Draft); + + Assert.Empty(records); + Assert.False(File.Exists(path)); + Assert.True(File.Exists(path + ".bak")); + } + // --- TryAdd tests / TryAdd テスト --- [Fact] From defc7abffb27f62ed263796d39e6403963a6370d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:47:14 +0900 Subject: [PATCH 4/9] Bound update check cache reads (#2870) --- changelog.d/unreleased/2870.security.md | 16 +++++++++++++ src/CodeIndex/Cli/UpdateChecker.cs | 7 +++++- tests/CodeIndex.Tests/ProgramRunnerTests.cs | 25 +++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/2870.security.md diff --git a/changelog.d/unreleased/2870.security.md b/changelog.d/unreleased/2870.security.md new file mode 100644 index 0000000000..3593ee16cd --- /dev/null +++ b/changelog.d/unreleased/2870.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 2870 +affected: + - src/CodeIndex/Cli/UpdateChecker.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Update-check cache reads are now bounded (#2870)** — update notification cache loading now ignores oversized cache files before JSON parsing instead of reading arbitrary file contents. + +## 日本語 + +- **update-check cache の読み込みに上限を設けました (#2870)** — update 通知 cache の読み込みは、任意サイズの file content を読むのではなく、JSON parse 前に oversized cache file を無視します。 diff --git a/src/CodeIndex/Cli/UpdateChecker.cs b/src/CodeIndex/Cli/UpdateChecker.cs index 952e621ad1..15b9f878f2 100644 --- a/src/CodeIndex/Cli/UpdateChecker.cs +++ b/src/CodeIndex/Cli/UpdateChecker.cs @@ -11,6 +11,7 @@ internal static class UpdateChecker private const string LatestReleaseUrl = "https://api.github.com/repos/Widthdom/CodeIndex/releases/latest"; internal const long MaxLatestReleaseResponseBytes = 64 * 1024; internal const int MaxLatestReleaseJsonDepth = 16; + internal const int MaxUpdateCheckCacheBytes = 8 * 1024; private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(2); @@ -183,7 +184,11 @@ private static string ResolveDefaultCachePath() if (!File.Exists(cachePath)) return null; - using var doc = JsonDocument.Parse(File.ReadAllText(cachePath)); + var text = DataDirectorySecurity.ReadTextWithinLimit(cachePath, MaxUpdateCheckCacheBytes, FileShare.ReadWrite); + if (text is null) + return null; + + using var doc = JsonDocument.Parse(text); var root = doc.RootElement; if (!root.TryGetProperty("checked_at", out var checkedAtElement) || !DateTimeOffset.TryParse( diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index d05b4a8240..45430a28b1 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -353,6 +353,31 @@ public void UpdateChecker_Check_ReportsNewerRelease() } } + [Fact] + public void UpdateChecker_Check_IgnoresOversizedCache() + { + var cachePath = Path.Combine(Path.GetTempPath(), $"cdidx_update_check_{Guid.NewGuid():N}.json"); + try + { + File.WriteAllText(cachePath, new string('x', UpdateChecker.MaxUpdateCheckCacheBytes + 1)); + + var result = UpdateChecker.Check( + "1.10.0", + cachePath, + DateTimeOffset.Parse("2026-01-01T00:00:00Z"), + _ => Task.FromResult("v1.11.0")); + + Assert.False(result.FromCache); + Assert.Equal("v1.11.0", result.LatestVersion); + Assert.True(result.UpdateAvailable); + } + finally + { + if (File.Exists(cachePath)) + File.Delete(cachePath); + } + } + [Theory] [InlineData("v1.26.0", "https://raw.githubusercontent.com/Widthdom/CodeIndex/v1.26.0/install.sh")] [InlineData(" release/test ", "https://raw.githubusercontent.com/Widthdom/CodeIndex/release%2Ftest/install.sh")] From cc115a2d5bae883071b616bd934f8655138ad5e8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:47:42 +0900 Subject: [PATCH 5/9] Bound git worktree metadata reads (#2858) --- changelog.d/unreleased/2858.security.md | 16 +++++++++++++++ src/CodeIndex/Cli/GitHelper.cs | 14 +++++++++++-- tests/CodeIndex.Tests/GitHelperTests.cs | 26 +++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/2858.security.md diff --git a/changelog.d/unreleased/2858.security.md b/changelog.d/unreleased/2858.security.md new file mode 100644 index 0000000000..b9324d86f2 --- /dev/null +++ b/changelog.d/unreleased/2858.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 2858 +affected: + - src/CodeIndex/Cli/GitHelper.cs + - tests/CodeIndex.Tests/GitHelperTests.cs +--- + +## English + +- **Git worktree metadata reads are now size-limited (#2858)** — `.git` files and worktree `commondir` files are ignored when oversized so repository-controlled git metadata cannot force unbounded CLI reads. + +## 日本語 + +- **git worktree metadata の読み込みにサイズ上限を設けました (#2858)** — `.git` file と worktree の `commondir` file が oversized の場合は無視し、リポジトリ管理下の git metadata による無制限 CLI 読み込みを防ぎます。 diff --git a/src/CodeIndex/Cli/GitHelper.cs b/src/CodeIndex/Cli/GitHelper.cs index 1d035ec1a6..036e86b2c4 100644 --- a/src/CodeIndex/Cli/GitHelper.cs +++ b/src/CodeIndex/Cli/GitHelper.cs @@ -37,6 +37,8 @@ public sealed record GitHeadCommitResult(GitHeadCommitState State, string? Sha = /// public static class GitHelper { + internal const int MaxGitMetadataFileBytes = 4 * 1024; + public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList UnresolvedMergeFiles); private static readonly HashSet UnresolvedMergeStatuses = new(StringComparer.Ordinal) @@ -73,7 +75,11 @@ public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList Unresolv : null; } - var gitFileContent = File.ReadAllText(ioDotGit).Trim(); + var gitFileContent = DataDirectorySecurity.ReadTextWithinLimit(ioDotGit, MaxGitMetadataFileBytes); + if (gitFileContent is null) + return null; + + gitFileContent = gitFileContent.Trim(); if (!gitFileContent.StartsWith("gitdir:")) return null; var worktreeGitDir = gitFileContent["gitdir:".Length..].Trim(); @@ -85,7 +91,11 @@ public sealed record WorktreeStatus(bool IsDirty, IReadOnlyList Unresolv var ioCommonDirFile = LongPath.EnsureWindowsPrefix(commonDirFile); if (File.Exists(ioCommonDirFile)) { - var commonDirRelative = File.ReadAllText(ioCommonDirFile).Trim(); + var commonDirRelative = DataDirectorySecurity.ReadTextWithinLimit(ioCommonDirFile, MaxGitMetadataFileBytes); + if (commonDirRelative is null) + return null; + + commonDirRelative = commonDirRelative.Trim(); return Path.GetFullPath(Path.Combine(worktreeGitDir, commonDirRelative)); } diff --git a/tests/CodeIndex.Tests/GitHelperTests.cs b/tests/CodeIndex.Tests/GitHelperTests.cs index 6fead55cf4..2ed018829d 100644 --- a/tests/CodeIndex.Tests/GitHelperTests.cs +++ b/tests/CodeIndex.Tests/GitHelperTests.cs @@ -106,6 +106,32 @@ public void GitFileWithInvalidContent_ReturnsNull() Assert.Null(result); } + [Fact] + public void GitFileWithOversizedContent_ReturnsNull() + { + File.WriteAllText(Path.Combine(_tempDir, ".git"), new string('x', GitHelper.MaxGitMetadataFileBytes + 1)); + + var result = GitHelper.ResolveGitCommonDir(_tempDir); + + Assert.Null(result); + } + + [Fact] + public void WorktreeWithOversizedCommonDir_ReturnsNull() + { + var worktreeGitDir = Path.Combine(_tempDir, "fake-git-dir"); + Directory.CreateDirectory(worktreeGitDir); + File.WriteAllText(Path.Combine(worktreeGitDir, "commondir"), new string('x', GitHelper.MaxGitMetadataFileBytes + 1)); + + var projectRoot = Path.Combine(_tempDir, "project"); + Directory.CreateDirectory(projectRoot); + File.WriteAllText(Path.Combine(projectRoot, ".git"), $"gitdir: {worktreeGitDir}"); + + var result = GitHelper.ResolveGitCommonDir(projectRoot); + + Assert.Null(result); + } + [Fact] public void WorktreeWithoutCommonDir_FallsBackToWorktreeGitDir() { From 8644a9b9803d44d8c3147c62028c17204d044ba0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:48:09 +0900 Subject: [PATCH 6/9] Bound git exclude reads (#2871) --- changelog.d/unreleased/2871.security.md | 16 ++++++++++++ src/CodeIndex/Cli/IndexCommandRunner.cs | 8 +++++- .../IndexCommandRunnerTests.cs | 25 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/2871.security.md diff --git a/changelog.d/unreleased/2871.security.md b/changelog.d/unreleased/2871.security.md new file mode 100644 index 0000000000..778d6ec653 --- /dev/null +++ b/changelog.d/unreleased/2871.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 2871 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +--- + +## English + +- **Git exclude checks are now size-limited before appending `.cdidx/` (#2871)** — the indexer skips modifying an oversized `.git/info/exclude` file instead of reading it without a cap. + +## 日本語 + +- **`.cdidx/` 追記前の git exclude 確認にサイズ上限を設けました (#2871)** — indexer は oversized な `.git/info/exclude` を無制限に読むのではなく、その file の更新をスキップします。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 2a071e26b3..ba8d33bb39 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -20,6 +20,7 @@ public static partial class IndexCommandRunner internal const string IncludeSymbolKindsEnvironmentVariable = "CDIDX_INDEX_INCLUDE_SYMBOL_KINDS"; internal const string ExcludeSymbolKindsEnvironmentVariable = "CDIDX_INDEX_EXCLUDE_SYMBOL_KINDS"; internal const int DefaultMaxSymbolsPerFile = 5000; + internal const int MaxGitExcludeBytes = 256 * 1024; private const string SymbolKindFilterMetaKey = "index_symbol_kind_filter"; private const int ScanCheckpointVersion = 1; private const string ScanCheckpointFileName = "scan-checkpoint.json"; @@ -731,7 +732,12 @@ private static void AddToGitExclude(string projectPath, string dbPath) } var ioExcludeFile = LongPath.EnsureWindowsPrefix(excludeFile); - var existingContent = File.Exists(ioExcludeFile) ? File.ReadAllText(ioExcludeFile) : ""; + var existingContent = File.Exists(ioExcludeFile) + ? DataDirectorySecurity.ReadTextWithinLimit(ioExcludeFile, MaxGitExcludeBytes, FileShare.ReadWrite) + : ""; + if (existingContent is null) + return; + var existingLines = existingContent.Split('\n').Select(l => l.TrimEnd('\r')).ToHashSet(); var missing = patterns.Where(p => !existingLines.Contains(p)).ToList(); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index b3e771f66a..31589a4b95 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -690,6 +690,31 @@ def helper(): } } + [Fact] + public void Run_FullScan_SkipsOversizedGitExclude() + { + var projectRoot = CreateTempProject(); + try + { + RunGit(projectRoot, "init"); + File.WriteAllText(Path.Combine(projectRoot, "app.cs"), "public class App { }\n"); + var excludePath = Path.Combine(projectRoot, ".git", "info", "exclude"); + File.WriteAllText(excludePath, new string('x', IndexCommandRunner.MaxGitExcludeBytes + 1)); + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("success", json.GetProperty("status").GetString()); + Assert.Equal(IndexCommandRunner.MaxGitExcludeBytes + 1, File.ReadAllText(excludePath).Length); + Assert.DoesNotContain("cdidx (CodeIndex)", File.ReadAllText(excludePath)); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_UpdateMode_RejectsNewSymbolKindFilterPolicy() { From 1b1dc166f272d07f7fdafe010cb1134e4d95d71d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:48:29 +0900 Subject: [PATCH 7/9] Bound hook marker reads (#2872) --- changelog.d/unreleased/2872.security.md | 16 ++++++++++ src/CodeIndex/Cli/HookCommandRunner.cs | 15 ++++++--- .../CodeIndex.Tests/HookCommandRunnerTests.cs | 32 +++++++++++++++++++ 3 files changed, 58 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/2872.security.md diff --git a/changelog.d/unreleased/2872.security.md b/changelog.d/unreleased/2872.security.md new file mode 100644 index 0000000000..c2bd3259cb --- /dev/null +++ b/changelog.d/unreleased/2872.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 2872 +affected: + - src/CodeIndex/Cli/HookCommandRunner.cs + - tests/CodeIndex.Tests/HookCommandRunnerTests.cs +--- + +## English + +- **Pre-commit hook marker checks are now bounded (#2872)** — hook install, uninstall, and status now treat oversized pre-commit hook files as custom hooks instead of reading the full file to find the managed marker. + +## 日本語 + +- **pre-commit hook marker の確認に上限を設けました (#2872)** — hook install / uninstall / status は managed marker を探すために pre-commit hook 全体を読むのではなく、oversized hook を custom hook として扱います。 diff --git a/src/CodeIndex/Cli/HookCommandRunner.cs b/src/CodeIndex/Cli/HookCommandRunner.cs index a721e20cb7..521a2e0489 100644 --- a/src/CodeIndex/Cli/HookCommandRunner.cs +++ b/src/CodeIndex/Cli/HookCommandRunner.cs @@ -9,6 +9,7 @@ public static class HookCommandRunner private const string ChainedHookName = "pre-commit.cdidx-chain"; private const string BeginMarker = "# BEGIN CDIDX MANAGED PRE-COMMIT"; private const string EndMarker = "# END CDIDX MANAGED PRE-COMMIT"; + internal const int MaxHookMarkerBytes = 64 * 1024; public static int Run(string[] args, JsonSerializerOptions jsonOptions) { @@ -83,8 +84,7 @@ private static int Install(HookCommandOptions options, JsonSerializerOptions jso var ioChainedHookPath = LongPath.EnsureWindowsPrefix(chainedHookPath); if (File.Exists(ioHookPath)) { - var existing = File.ReadAllText(ioHookPath); - if (!IsManagedHook(existing)) + if (!IsManagedHookFile(ioHookPath)) { if (File.Exists(ioChainedHookPath) && !options.Force) return WriteResult(options.Json, jsonOptions, "error", $"chained hook already exists: {chainedHookPath}", projectPath, hookPath, chainedHookPath, CommandExitCodes.UsageError); @@ -107,8 +107,7 @@ private static int Uninstall(HookCommandOptions options, JsonSerializerOptions j if (!File.Exists(ioHookPath)) return WriteResult(options.Json, jsonOptions, "absent", "cdidx pre-commit hook is not installed", projectPath, hookPath, File.Exists(ioChainedHookPath) ? chainedHookPath : null, CommandExitCodes.Success); - var existing = File.ReadAllText(ioHookPath); - if (!IsManagedHook(existing) && !options.Force) + if (!IsManagedHookFile(ioHookPath) && !options.Force) return WriteResult(options.Json, jsonOptions, "error", "pre-commit hook is not managed by cdidx; pass --force to remove it", projectPath, hookPath, null, CommandExitCodes.UsageError); File.Delete(ioHookPath); @@ -123,7 +122,7 @@ private static int Status(HookCommandOptions options, JsonSerializerOptions json var ioHookPath = LongPath.EnsureWindowsPrefix(hookPath); var ioChainedHookPath = LongPath.EnsureWindowsPrefix(chainedHookPath); var hookExists = File.Exists(ioHookPath); - var installed = hookExists && IsManagedHook(File.ReadAllText(ioHookPath)); + var installed = hookExists && IsManagedHookFile(ioHookPath); var status = installed ? "installed" : hookExists ? "custom" : "absent"; return WriteResult(options.Json, jsonOptions, status, $"cdidx pre-commit hook is {status}", projectPath, hookPath, File.Exists(ioChainedHookPath) ? chainedHookPath : null, CommandExitCodes.Success); } @@ -138,6 +137,12 @@ private static int UnknownCommand(HookCommandOptions options, JsonSerializerOpti private static bool IsManagedHook(string content) => content.Contains(BeginMarker, StringComparison.Ordinal) && content.Contains(EndMarker, StringComparison.Ordinal); + private static bool IsManagedHookFile(string ioHookPath) + { + var content = DataDirectorySecurity.ReadTextWithinLimit(ioHookPath, MaxHookMarkerBytes, FileShare.ReadWrite); + return content is not null && IsManagedHook(content); + } + private static string BuildHookScript(string chainedHookPath) { var quotedChainedHook = QuoteShell(chainedHookPath); diff --git a/tests/CodeIndex.Tests/HookCommandRunnerTests.cs b/tests/CodeIndex.Tests/HookCommandRunnerTests.cs index 0d32d3348c..baf9b3aabc 100644 --- a/tests/CodeIndex.Tests/HookCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/HookCommandRunnerTests.cs @@ -92,6 +92,38 @@ public void Hooks_Install_ChainsExistingPreCommitHook() } } + [Fact] + public void Hooks_TreatsOversizedPreCommitHookAsCustom() + { + var projectRoot = TestProjectHelper.CreateTempProject("hook_oversized"); + try + { + TestProjectHelper.InitializeGitRepo(projectRoot); + var hooksDir = Path.Combine(projectRoot, ".git", "hooks"); + Directory.CreateDirectory(hooksDir); + var hookPath = Path.Combine(hooksDir, "pre-commit"); + var chainedHookPath = Path.Combine(hooksDir, "pre-commit.cdidx-chain"); + File.WriteAllText(hookPath, new string('x', HookCommandRunner.MaxHookMarkerBytes + 1)); + + var (statusExit, statusStdout, _) = RunHooksAndCaptureStreams(["status", "--project", projectRoot, "--json"]); + var uninstallExit = RunHooksAndCaptureStreams(["uninstall", "--project", projectRoot]).ExitCode; + var installExit = RunHooksAndCaptureStreams(["install", "--project", projectRoot]).ExitCode; + + Assert.Equal(CommandExitCodes.Success, statusExit); + using (var document = JsonDocument.Parse(statusStdout)) + Assert.Equal("custom", document.RootElement.GetProperty("status").GetString()); + Assert.Equal(CommandExitCodes.UsageError, uninstallExit); + Assert.Equal(CommandExitCodes.Success, installExit); + Assert.True(File.Exists(chainedHookPath)); + Assert.Equal(HookCommandRunner.MaxHookMarkerBytes + 1, File.ReadAllText(chainedHookPath).Length); + Assert.Contains("BEGIN CDIDX MANAGED PRE-COMMIT", File.ReadAllText(hookPath)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Index_QuietSuppressesSuccessfulHumanOutput() { From 70c967601313a97e4d3d8ed603c9719b3e8c9347 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 01:48:51 +0900 Subject: [PATCH 8/9] Bound DbPathResolver sample checksum reads (#2867) --- changelog.d/unreleased/2867.security.md | 17 ++++ src/CodeIndex/Cli/DbPathResolver.cs | 6 +- src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 97 +++++++++++++++++-- tests/CodeIndex.Tests/DbPathResolverTests.cs | 37 +++++++ 4 files changed, 146 insertions(+), 11 deletions(-) create mode 100644 changelog.d/unreleased/2867.security.md diff --git a/changelog.d/unreleased/2867.security.md b/changelog.d/unreleased/2867.security.md new file mode 100644 index 0000000000..e6c4b824c0 --- /dev/null +++ b/changelog.d/unreleased/2867.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2867 +affected: + - src/CodeIndex/Cli/DbPathResolver.cs + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - tests/CodeIndex.Tests/DbPathResolverTests.cs +--- + +## English + +- **DbPathResolver checksum probes no longer read whole sample files (#2867)** — explicit `.cdidx/codeindex.db` project-root probing now computes sample checksums through a bounded streaming helper and skips checksum comparison for files over the indexer size cap. + +## 日本語 + +- **DbPathResolver の checksum probe が sample file 全体を読み込まないようになりました (#2867)** — 明示指定された `.cdidx/codeindex.db` の project-root 推定では、上限制御された streaming helper で sample checksum を計算し、indexer の size cap を超える file は checksum 比較から外します。 diff --git a/src/CodeIndex/Cli/DbPathResolver.cs b/src/CodeIndex/Cli/DbPathResolver.cs index 357b70f30c..92ccc6531e 100644 --- a/src/CodeIndex/Cli/DbPathResolver.cs +++ b/src/CodeIndex/Cli/DbPathResolver.cs @@ -519,9 +519,11 @@ private static SampleMatchResult CountMatchingSamples(string candidateRoot, IRea // checksums recorded by an indexer running on a different OS. // FileIndexer のヘルパを使い、OS をまたいだ clone (CRLF と LF) でも、 // 他 OS で生成された checksum と引き続き一致するようにする。 - var checksum = FileIndexer.ComputeChecksum(File.ReadAllBytes(ioPath)); - if (string.Equals(checksum, sample.Checksum, StringComparison.Ordinal)) + if (FileIndexer.TryComputeChecksum(ioPath, FileIndexer.DefaultMaxFileSizeBytes, out var checksum) && + string.Equals(checksum, sample.Checksum, StringComparison.Ordinal)) + { checksumMatches++; + } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 9ac7ab21f4..8e5da8f1c5 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -3747,29 +3747,108 @@ private static int CountReplacementChars(string content) internal static string ComputeChecksum(byte[] bytes) { using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); - Span buffer = stackalloc byte[4096]; - int n = 0; - for (int i = 0; i < bytes.Length; i++) + var pendingCarriageReturn = false; + AppendNormalizedChecksumBytes(hasher, bytes, ref pendingCarriageReturn); + FlushPendingChecksumCarriageReturn(hasher, ref pendingCarriageReturn); + return FinishChecksum(hasher); + } + + internal static bool TryComputeChecksum(string filePath, long maxBytes, out string checksum) + { + if (maxBytes < 0) + throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "Maximum byte count must be non-negative."); + + checksum = string.Empty; + using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + using var stream = new FileStream( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + bufferSize: 81920, + options: FileOptions.SequentialScan); + + var buffer = new byte[81920]; + var pendingCarriageReturn = false; + long total = 0; + while (true) + { + var read = stream.Read(buffer, 0, buffer.Length); + if (read == 0) + break; + + total += read; + if (total > maxBytes) + return false; + + AppendNormalizedChecksumBytes(hasher, buffer.AsSpan(0, read), ref pendingCarriageReturn); + } + + FlushPendingChecksumCarriageReturn(hasher, ref pendingCarriageReturn); + checksum = FinishChecksum(hasher); + return true; + } + + private static void AppendNormalizedChecksumBytes( + IncrementalHash hasher, + ReadOnlySpan bytes, + ref bool pendingCarriageReturn) + { + Span normalized = stackalloc byte[4096]; + var n = 0; + + if (pendingCarriageReturn) { - byte b = bytes[i]; + if (bytes.Length > 0 && bytes[0] == 0x0A) + bytes = bytes[1..]; + normalized[n++] = 0x0A; + pendingCarriageReturn = false; + } + + for (var i = 0; i < bytes.Length; i++) + { + var b = bytes[i]; if (b == 0x0D) { - buffer[n++] = 0x0A; + if (i + 1 == bytes.Length) + { + pendingCarriageReturn = true; + continue; + } + + normalized[n++] = 0x0A; if (i + 1 < bytes.Length && bytes[i + 1] == 0x0A) i++; } else { - buffer[n++] = b; + normalized[n++] = b; } - if (n == buffer.Length) + + if (n == normalized.Length) { - hasher.AppendData(buffer); + hasher.AppendData(normalized); n = 0; } } + if (n > 0) - hasher.AppendData(buffer[..n]); + hasher.AppendData(normalized[..n]); + } + + private static void FlushPendingChecksumCarriageReturn(IncrementalHash hasher, ref bool pendingCarriageReturn) + { + if (!pendingCarriageReturn) + return; + + Span lineFeed = stackalloc byte[1]; + lineFeed[0] = 0x0A; + hasher.AppendData(lineFeed); + pendingCarriageReturn = false; + } + + private static string FinishChecksum(IncrementalHash hasher) + { Span hash = stackalloc byte[32]; if (!hasher.TryGetHashAndReset(hash, out var written) || written != hash.Length) throw new InvalidOperationException("SHA256 produced an unexpected hash length"); diff --git a/tests/CodeIndex.Tests/DbPathResolverTests.cs b/tests/CodeIndex.Tests/DbPathResolverTests.cs index 3905fff8ce..7e324c1629 100644 --- a/tests/CodeIndex.Tests/DbPathResolverTests.cs +++ b/tests/CodeIndex.Tests/DbPathResolverTests.cs @@ -1,5 +1,6 @@ using CodeIndex.Cli; using CodeIndex.Database; +using CodeIndex.Indexer; using Microsoft.Data.Sqlite; namespace CodeIndex.Tests; @@ -643,6 +644,42 @@ public void ResolveProjectRootForQuery_ExplicitExternalCodeIndexDbWithoutMetadat } } + [Fact] + public void ResolveProjectRootForQuery_ExplicitExternalCodeIndexDbSkipsOversizedSiblingChecksumSample() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_oversized_root"); + var dbContainerRoot = TestProjectHelper.CreateTempProject("cdidx_db_path_resolver_oversized_container"); + var dbPath = Path.Combine(dbContainerRoot, ".cdidx", "codeindex.db"); + try + { + Directory.CreateDirectory(Path.GetDirectoryName(dbPath)!); + Directory.CreateDirectory(Path.Combine(projectRoot, "src")); + Directory.CreateDirectory(Path.Combine(dbContainerRoot, "src")); + + const string indexedContent = "class App {}\n"; + File.WriteAllText(Path.Combine(projectRoot, "src", "app.cs"), indexedContent); + using (var stream = File.Create(Path.Combine(dbContainerRoot, "src", "app.cs"))) + stream.SetLength(FileIndexer.DefaultMaxFileSizeBytes + 1); + + using (var db = new DbContext(dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + writer.SetMeta(DbContext.IndexedProjectRootMetaKey, projectRoot); + } + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", indexedContent); + + var resolved = DbPathResolver.ResolveProjectRootForQuery(dbPath, dbPathExplicit: true); + + Assert.Equal(projectRoot, resolved); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(dbContainerRoot); + } + } + [Fact] public void ResolveProjectRootForQuery_ReturnsNullForExplicitDbWithoutMetadata() { From f0ba3a2eeae49fb8ef2ffb2c544f81e88f6e3aba Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 3 Jun 2026 02:47:15 +0900 Subject: [PATCH 9/9] Preserve bounded text BOM handling (#2844) --- src/CodeIndex/Cli/DataDirectorySecurity.cs | 9 +++++- tests/CodeIndex.Tests/CdidxConfigFileTests.cs | 21 ++++++++++++++ .../WorkspaceCommandRunnerTests.cs | 28 +++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/DataDirectorySecurity.cs b/src/CodeIndex/Cli/DataDirectorySecurity.cs index a13a5d30a1..f5f1a0c877 100644 --- a/src/CodeIndex/Cli/DataDirectorySecurity.cs +++ b/src/CodeIndex/Cli/DataDirectorySecurity.cs @@ -85,7 +85,14 @@ public static void WritePrivateText(string path, string contents, Encoding? enco public static string? ReadTextWithinLimit(string path, int maxBytes, FileShare share = FileShare.Read) { var bytes = ReadBytesWithinLimit(path, maxBytes, share); - return bytes is null ? null : Encoding.UTF8.GetString(bytes); + return bytes is null ? null : DecodeText(bytes); + } + + private static string DecodeText(byte[] bytes) + { + using var stream = new MemoryStream(bytes); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); } public static string? GetUnixModeString(string? path) diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index c9260228fa..60d8a36c4a 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -1,4 +1,5 @@ using CodeIndex.Cli; +using System.Text; namespace CodeIndex.Tests; @@ -72,6 +73,26 @@ public void LoadAndApply_MaterializesKnownKeysIntoEnvironment() finally { TestProjectHelper.DeleteDirectory(dir); } } + [Fact] + public void LoadAndApply_Utf8BomConfigMaterializesKnownKeysIntoEnvironment() + { + var dir = CreateTempDir(); + try + { + var path = Path.Combine(dir, ".cdidxrc.json"); + var json = """{ "metrics_path": "/tmp/bom.jsonl" }"""; + File.WriteAllBytes(path, [0xEF, 0xBB, 0xBF, .. Encoding.UTF8.GetBytes(json)]); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Loaded); + Assert.Null(result.Error); + Assert.Equal("/tmp/bom.jsonl", env.Writes["CDIDX_METRICS"]); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + [Fact] public void LoadAndApply_ProjectConfigJsonMaterializesSearchDefaults() { diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index b100d65f47..a56ab32f88 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -1,4 +1,5 @@ using CodeIndex.Cli; +using System.Text; using System.Text.Json; namespace CodeIndex.Tests; @@ -63,6 +64,33 @@ public void WorkspaceManifestLoader_Load_RejectsOversizedManifest() } } + [Fact] + public void WorkspaceManifestLoader_Load_AcceptsUtf8BomManifest() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_bom"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + var json = """ + { + "members": ["src/A"], + "index_strategy": "per_member", + "default_db_name": "index.db" + } + """; + File.WriteAllBytes(manifestPath, [0xEF, 0xBB, 0xBF, .. Encoding.UTF8.GetBytes(json)]); + + var manifest = WorkspaceManifestLoader.Load(manifestPath); + + Assert.Equal("index.db", manifest.DefaultDbName); + Assert.Single(manifest.Members); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceErrors_HonorJsonFlag() {