From e91b14c5bad737409d52f7ff672f6b1fd1906b5f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 10:02:28 +0900 Subject: [PATCH 1/4] Fix symlink preflight consistency (#4829) --- DEVELOPER_GUIDE.md | 4 +- changelog.d/unreleased/4829.fixed.md | 21 ++++ .../Cli/IndexCommandRunner.DryRun.cs | 18 ++- src/CodeIndex/Cli/JsonOutputContracts.cs | 4 + .../Indexer/CSharpStaticInterfacePrepass.cs | 26 +++- .../Scanning/FileContentLoader.RawBytes.cs | 10 +- .../Indexer/Scanning/FileContentLoader.cs | 5 +- .../Scanning/FileIndexer.PathComparison.cs | 15 +++ .../Scanning/FileIndexer.PathFiltering.cs | 4 +- .../IndexCommandRunnerDryRunTests.cs | 116 ++++++++++++++++++ .../IndexCommandRunnerFullScanTests.cs | 67 ++++++++++ 11 files changed, 274 insertions(+), 16 deletions(-) create mode 100644 changelog.d/unreleased/4829.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 201d055b9..4ba220760 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -272,7 +272,7 @@ valid encoded U+FFFD literal, while `origin: decode_replacement` means the decoder inserted U+FFFD for invalid bytes. `severity: info` is used for source literals, and `severity: warning` is used for likely encoding damage. -Scoped `--files` / `--commits` refreshes reuse the same path filter as full scans. Before scanning a nested project root, `FileIndexer` loads ignore files from the resolved ignore-rule root through each existing ancestor directory down to the project root's parent, then loads the project directory's own rules during the normal walk. Within each directory, `FileIndexer` loads `.gitignore` before `.cdidxignore`, appends both rule sets in that order, and honors later `!` patterns as re-includes. If an ancestor ignore directory cannot be read, scanning fails closed with a scan error instead of silently skipping those rules; `ScanFilesResult.AncestorIgnoreDirectories` records the resolved ancestor list for troubleshooting. If a commit-scoped refresh includes `.gitignore` or `.cdidxignore` changes, `IndexCommandRunner` falls back to a full scan so newly ignored files are purged safely. Malformed ignore lines are reported as scan errors and skipped instead of aborting the whole run. Symlinks default to `--follow-symlinks none`; `internal` follows file and directory targets that resolve under the workspace root, and `all` follows all resolvable targets. Dangling symlinks are counted and warned separately, while permission failures resolving directory targets are reported as scan warnings. On Windows, files and directories with Hidden or System attributes are rejected before language detection; clear those attributes before indexing project-owned sources because ignore rules cannot re-include them. +Scoped `--files` / `--commits` refreshes reuse the same path filter as full scans. Before scanning a nested project root, `FileIndexer` loads ignore files from the resolved ignore-rule root through each existing ancestor directory down to the project root's parent, then loads the project directory's own rules during the normal walk. Within each directory, `FileIndexer` loads `.gitignore` before `.cdidxignore`, appends both rule sets in that order, and honors later `!` patterns as re-includes. If an ancestor ignore directory cannot be read, scanning fails closed with a scan error instead of silently skipping those rules; `ScanFilesResult.AncestorIgnoreDirectories` records the resolved ancestor list for troubleshooting. If a commit-scoped refresh includes `.gitignore` or `.cdidxignore` changes, `IndexCommandRunner` falls back to a full scan so newly ignored files are purged safely. Malformed ignore lines are reported as scan errors and skipped instead of aborting the whole run. Symlinks default to `--follow-symlinks none`; `internal` follows file and directory targets that resolve under the workspace root, and `all` follows all resolvable targets. Discovery, dry-run, C# workspace preflight, and content loading use the same resolved file target identity, so a stable allowed external file target is indexed while a link retargeted after preflight is rejected as source drift. Dangling symlinks are counted and warned separately; index dry-run reports them through `warnings_total` / `warnings`, matching execution severity and successful exit behavior. Permission failures resolving directory targets are also reported as scan warnings. On Windows, files and directories with Hidden or System attributes are rejected before language detection; clear those attributes before indexing project-owned sources because ignore rules cannot re-include them. Incremental refreshes that mutate `fts_chunks` increment both `codeindex_meta.fts_incremental_writes_since_merge` and `codeindex_meta.fts_incremental_writes_since_optimize`. When the merge counter reaches 25 writes, index runners issue `INSERT INTO fts_chunks(fts_chunks, rank) VALUES('merge', -1000)`: 1,000 pages is a minimum work target, and SQLite's complete-segment granularity may process more pages. The merge resets only its dedicated counter, while the optimize counter continues to support the `cdidx optimize --dry-run` recommendation. Full CLI scans and MCP refreshes switch to trigger-free bulk rewrite, FTS rebuild, and full optimize when dirty source bytes are at least three-fifths of known workspace source bytes; fresh indexes and explicit rebuilds always use that path. Dirty bytes include the larger of the current and persisted sizes for each file that will be rewritten, plus persisted byte sizes for indexed rows planned for deletion, including the old side of a rename. The comparison total includes known readable current-workspace bytes, those planned-deletion bytes, and the positive persisted-minus-current excess for rewritten files that shrank, so both sides describe the same pre-update footprint. A scan error, invalid persisted size, or byte-count overflow makes the estimate incomplete and conservatively keeps trigger synchronization. Stale-file IDs are planned without mutation before selecting the FTS policy, then deleted inside the selected bulk guard. The plan keeps IDs ascending so the C# static-interface workspace prepass can skip purge-planned rows with a binary search and no duplicate deletion set; the reusable-stat snapshot instead loads the IDs into an indexed temporary SQL filter before running eligibility subqueries. This prevents a path that reappears between MCP planning and scanning from reusing a row that the same run will purge. The separate pre-purge contract-presence query runs only for a non-empty plan and still forces C# re-extraction when deletion may remove implicit implementation references. When such contracts exist, MCP also invalidates the C# symbol-name contract at its first mutation; if a scan error leaves an implementer unprocessed after the purge, the next clean run disables stat reuse, repairs its implicit references, and only then restamps the contract. The batched delete transaction checks cancellation throughout and rolls back every delete if cancellation arrives before commit; if cancellation arrives after a committed bulk purge, guard abandonment rebuilds FTS from the surviving chunks and restores its triggers before the run exits. Full scans and MCP refreshes filter reusable-row snapshots with a current-target path set only when non-purged indexed rows unused by the current target set outnumber the current targets; other runs use sorted-ID exclusion without duplicating every current path in a second large set. Scoped `--files` / `--commits` refreshes stay on trigger synchronization and incremental merge maintenance. `cdidx optimize --db ` and `cdidx index --optimize` still run an explicit full optimize, reset both counters, and stamp `fts_last_optimized_at`; this may briefly hold the writer lock on large indexes. @@ -3373,7 +3373,7 @@ literal、`origin: decode_replacement` が不正 byte に対して decoder が を意味する。source literal は `severity: info`、エンコーディング破損の可能性は `severity: warning` として返す。 -`--files` / `--commits` の部分更新も、フルスキャンと同じパスフィルタを再利用する。各ディレクトリでは `FileIndexer` が `.gitignore` を `.cdidxignore` より先に読み、この順序でルールを追加し、後続の `!` パターンを再包含として扱う。commit 単位更新に `.gitignore` または `.cdidxignore` の変更が含まれる場合、`IndexCommandRunner` は newly ignored file を安全に purge するため自動でフルスキャンへフォールバックする。malformed な ignore 行は走査エラーとして報告し、その行だけをスキップして index 全体は継続する。Windows では Hidden または System 属性が付いたファイルとディレクトリを言語検出前に拒否する。プロジェクト所有のソースを索引したい場合、ignore ルールでは再包含できないため先にそれらの属性を外す。 +`--files` / `--commits` の部分更新も、フルスキャンと同じパスフィルタを再利用する。各ディレクトリでは `FileIndexer` が `.gitignore` を `.cdidxignore` より先に読み、この順序でルールを追加し、後続の `!` パターンを再包含として扱う。commit 単位更新に `.gitignore` または `.cdidxignore` の変更が含まれる場合、`IndexCommandRunner` は newly ignored file を安全に purge するため自動でフルスキャンへフォールバックする。malformed な ignore 行は走査エラーとして報告し、その行だけをスキップして index 全体は継続する。symlink は既定で `--follow-symlinks none` とし、`internal` は workspace root 内へ解決される file / directory target、`all` は解決可能なすべての target を追跡する。discovery、dry-run、C# workspace preflight、content loading は同じ解決済み file target identity を使うため、許可された静的な外部 file target は索引し、preflight 後に retarget された link は source drift として拒否する。dangling symlink は個別に集計して warning とし、index dry-run も実行時と同じく `warnings_total` / `warnings` で報告して成功終了する。directory target の解決時に発生した permission failure も scan warning として報告する。Windows では Hidden または System 属性が付いたファイルとディレクトリを言語検出前に拒否する。プロジェクト所有のソースを索引したい場合、ignore ルールでは再包含できないため先にそれらの属性を外す。 ### メタデータ不変条件 diff --git a/changelog.d/unreleased/4829.fixed.md b/changelog.d/unreleased/4829.fixed.md new file mode 100644 index 000000000..2cd5455f0 --- /dev/null +++ b/changelog.d/unreleased/4829.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 4829 +affected: + - src/CodeIndex/Indexer/Scanning/FileIndexer.PathFiltering.cs + - src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs + - src/CodeIndex/Indexer/Scanning/FileContentLoader.cs + - src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs + - src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs + - src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs +--- + +## English + +- **Symlink preflight now agrees with dry-run and execution (#4829)** — allowed external file links use their resolved target metadata consistently, retargeted links still fail source-drift validation, and dangling links remain successful warnings in both execution and dry-run JSON (`warnings_total` / `warnings`). + +## 日本語 + +- **symlink の preflight が dry-run と実行で一致するようになりました (#4829)** — 許可された外部 file link は解決済み target の metadata を一貫して使用し、retarget された link は引き続き source-drift 検証で拒否します。dangling link は実行時と dry-run JSON(`warnings_total` / `warnings`)のどちらでも成功扱いの warning になります。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs index b3f896a7e..71e0d2222 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs @@ -10,6 +10,7 @@ public static partial class IndexCommandRunner { internal const int DryRunFileSampleLimit = 100; internal const int DryRunLanguageDetectionLimit = 100; + internal const int DryRunWarningSampleLimit = 100; internal const int DryRunErrorSampleLimit = 100; internal const int DefaultDryRunPathLimit = 100_000; internal const int MaxDryRunPathLimit = 1_000_000; @@ -43,6 +44,8 @@ private static int RunDryRun( bool authoritativeFullScan; var errorSamples = new List(); var errorCount = 0; + var warningSamples = new List(); + var warningCount = 0; var dryScanErrorKeys = new HashSet(StringComparer.Ordinal); DryRunScanMetadata dryScanMetadata; var dbSnapshot = ReadDryRunDbSnapshot(resolvedDbPath, options.SymbolKindFilter); @@ -104,7 +107,16 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) continue; } - RecordDryRunError(scanError.Path, scanError.Message); + if (scanError.Severity == FileIndexer.ScanIssueSeverity.Warning) + { + warningCount++; + if (warningSamples.Count < DryRunWarningSampleLimit) + warningSamples.Add(new CliJsonMessage(scanError.Path, scanError.Message)); + } + else + { + RecordDryRunError(scanError.Path, scanError.Message); + } if (!options.Json) ConsoleUi.PrintWarning($"{scanError.Path}: {scanError.Message}"); } @@ -300,6 +312,10 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) LanguageDetections = languageDetectionSamples.Count > 0 ? languageDetectionSamples : null, LanguageDetectionsTruncated = languageDetectionTotal > languageDetectionSamples.Count, LanguageDetectionLimit = DryRunLanguageDetectionLimit, + WarningsTotal = warningCount, + Warnings = warningSamples.Count > 0 ? warningSamples : null, + WarningsTruncated = warningCount > warningSamples.Count, + WarningLimit = DryRunWarningSampleLimit, ErrorsTotal = errorCount, Errors = errorSamples.Count > 0 ? errorSamples : null, ErrorsTruncated = errorCount > errorSamples.Count, diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 2eba86b00..20b2bfb90 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -706,6 +706,10 @@ internal sealed class IndexDryRunJsonResult : IVersionedJsonResult public List? LanguageDetections { get; init; } public bool LanguageDetectionsTruncated { get; init; } public int LanguageDetectionLimit { get; init; } + public int WarningsTotal { get; init; } + public List? Warnings { get; init; } + public bool WarningsTruncated { get; init; } + public int WarningLimit { get; init; } public int ErrorsTotal { get; init; } public List? Errors { get; init; } public bool ErrorsTruncated { get; init; } diff --git a/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs b/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs index 86d2ac1df..558ef0013 100644 --- a/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs +++ b/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs @@ -656,7 +656,8 @@ internal static bool TryCaptureFileStatSnapshots( try { validateTarget?.Invoke(target); - var info = new FileInfo(LongPath.EnsureWindowsPrefix(target.FilePath)); + var resolvedPath = FileIndexer.ResolveFileReadPath(target.FilePath); + var info = new FileInfo(LongPath.EnsureWindowsPrefix(resolvedPath)); info.Refresh(); if (!info.Exists) { @@ -664,7 +665,10 @@ internal static bool TryCaptureFileStatSnapshots( return false; } - snapshots[target.IndexPath] = new FileStatSnapshot(info.Length, info.LastWriteTimeUtc); + snapshots[target.IndexPath] = new FileStatSnapshot( + info.Length, + info.LastWriteTimeUtc, + resolvedPath); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException @@ -686,7 +690,10 @@ internal static bool FileStatSnapshotsMatch( { foreach (var (path, snapshot) in before) { - if (!after.TryGetValue(path, out var current) || current != snapshot) + if (!after.TryGetValue(path, out var current) + || current.Size != snapshot.Size + || current.ModifiedUtc != snapshot.ModifiedUtc + || !FileIndexer.FileReadPathsEqual(current.ResolvedPath, snapshot.ResolvedPath)) { changedPath = path; return false; @@ -722,10 +729,12 @@ internal static bool TryValidateFileStatSnapshots( try { validateTarget?.Invoke(target); - var info = new FileInfo(LongPath.EnsureWindowsPrefix(target.FilePath)); + var resolvedPath = FileIndexer.ResolveFileReadPath(target.FilePath); + var info = new FileInfo(LongPath.EnsureWindowsPrefix(resolvedPath)); info.Refresh(); if (!info.Exists || !snapshots.TryGetValue(target.IndexPath, out var snapshot) + || !FileIndexer.FileReadPathsEqual(resolvedPath, snapshot.ResolvedPath) || info.Length != snapshot.Size || info.LastWriteTimeUtc != snapshot.ModifiedUtc) { @@ -770,9 +779,11 @@ internal static bool TryValidateLoadedFileStatSnapshot( try { validatePath?.Invoke(filePath); - var info = new FileInfo(LongPath.EnsureWindowsPrefix(filePath)); + var resolvedPath = FileIndexer.ResolveFileReadPath(filePath); + var info = new FileInfo(LongPath.EnsureWindowsPrefix(resolvedPath)); info.Refresh(); if (!info.Exists + || !FileIndexer.FileReadPathsEqual(resolvedPath, snapshot.ResolvedPath) || info.Length != snapshot.Size || info.LastWriteTimeUtc != snapshot.ModifiedUtc) { @@ -956,7 +967,10 @@ public static FileTarget Create(string projectRoot, string filePath, string? lan } } - internal readonly record struct FileStatSnapshot(long Size, DateTime ModifiedUtc); + internal readonly record struct FileStatSnapshot( + long Size, + DateTime ModifiedUtc, + string ResolvedPath); internal readonly record struct DirectoryStatSnapshot(DateTime ModifiedUtc); diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs index 139d8f743..26a456108 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs @@ -28,11 +28,12 @@ internal sealed partial class FileContentLoader byte[] bytes; long sizeBytes; DateTime modifiedUtc; - var ioPath = LongPath.EnsureWindowsPrefix(absolutePath); + var readPath = FileIndexer.ResolveFileReadPath(absolutePath); + var ioPath = LongPath.EnsureWindowsPrefix(readPath); for (var attempt = 0; ; attempt++) { var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); - using (var stream = _openReadForIndexContent(absolutePath)) + using (var stream = _openReadForIndexContent(readPath)) { var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) @@ -62,12 +63,13 @@ internal bool RawByteChunksMayMatch( RawByteChunkPredicate chunkPredicate, CancellationToken cancellationToken) { - var ioPath = LongPath.EnsureWindowsPrefix(absolutePath); + var readPath = FileIndexer.ResolveFileReadPath(absolutePath); + var ioPath = LongPath.EnsureWindowsPrefix(readPath); for (var attempt = 0; ; attempt++) { var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); bool matched; - using (var stream = _openReadForIndexContent(absolutePath)) + using (var stream = _openReadForIndexContent(readPath)) { var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs index 7ba7a443e..c41951272 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs @@ -100,12 +100,13 @@ internal string LoadNormalizedContentForPrepass( bool retryOnMutation, CancellationToken cancellationToken) { - var ioPath = LongPath.EnsureWindowsPrefix(absolutePath); + var readPath = FileIndexer.ResolveFileReadPath(absolutePath); + var ioPath = LongPath.EnsureWindowsPrefix(readPath); cancellationToken.ThrowIfCancellationRequested(); var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); byte[]? bytes = null; bool lengthChanged; - using (var stream = _openReadForIndexContent(absolutePath)) + using (var stream = _openReadForIndexContent(readPath)) { var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs index 999192dc9..7bc19cb36 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs @@ -14,6 +14,21 @@ private static bool IsPathEqualOrParent(string candidateParent, string candidate return CodeIndex.Cli.PathCasing.IsPathEqualOrParent(normalizedParent, normalizedChild); } + private static bool IsLexicalPathEqualOrParent(string candidateParent, string candidateChild) + { + var normalizedParent = Path.TrimEndingDirectorySeparator(Path.GetFullPath(candidateParent)); + var normalizedChild = Path.TrimEndingDirectorySeparator(Path.GetFullPath(candidateChild)); + return CodeIndex.Cli.PathCasing.IsPathEqualOrParent(normalizedParent, normalizedChild); + } + + internal static string ResolveFileReadPath(string path) + => NormalizePathForComparison(path); + + internal static bool FileReadPathsEqual(string left, string right) + => CodeIndex.Cli.PathCasing.PathsEqual( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(right))); + private static string NormalizePathForComparison(string path) { var fullPath = Path.GetFullPath(path); diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.PathFiltering.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.PathFiltering.cs index b39d4fbba..6ddfe270e 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.PathFiltering.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.PathFiltering.cs @@ -66,7 +66,9 @@ private PathFilterResult EvaluatePathFilterCore( } fullPath = Path.GetFullPath(absolutePath); - if (!IsPathEqualOrParent(_projectRoot, fullPath)) + if (!IsLexicalPathEqualOrParent(_projectRoot, fullPath) + || (_symlinkPolicy != SymlinkPolicy.All + && !IsPathEqualOrParent(_projectRoot, fullPath))) return CreatePathFilterResult(PathFilterKind.OutsideProjectRoot, errors); relativePath = NormalizeIgnorePath(GetRelativePathFromProjectRoot(_projectRoot, fullPath)); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs index 4202d7291..c775a7257 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerDryRunTests.cs @@ -839,6 +839,122 @@ public void Run_DryRun_WithFiles_RejectsSymlinkSkippedByPolicy_4471() } } + [Fact] + public void Run_DryRunAndFullScan_FollowSymlinksAllAgreeForExternalFileLink_Issue4829() + { + var projectRoot = CreateTempProject(); + var outsideRoot = CreateTempProject(); + try + { + var targetPath = Path.Combine(outsideRoot, "Outside.cs"); + var linkPath = Path.Combine(projectRoot, "OutsideLink.cs"); + File.WriteAllText(targetPath, "public class Outside4829 { }\n"); + try + { + File.CreateSymbolicLink(linkPath, targetPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var (dryRunExitCode, dryRunJson) = RunAndCaptureJson([ + projectRoot, + "--follow-symlinks", + "all", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal(1, dryRunJson.GetProperty("files_total").GetInt32()); + Assert.Equal(1, dryRunJson.GetProperty("projected_file_updates").GetInt32()); + Assert.Equal(0, dryRunJson.GetProperty("warnings_total").GetInt32()); + Assert.Equal(0, dryRunJson.GetProperty("errors_total").GetInt32()); + Assert.Equal(1, dryRunJson.GetProperty("languages").GetProperty("csharp").GetInt32()); + + var (indexExitCode, indexJson) = RunAndCaptureJson([ + projectRoot, + "--follow-symlinks", + "all", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal("success", indexJson.GetProperty("status").GetString()); + Assert.Equal(1, indexJson.GetProperty("summary").GetProperty("files_total").GetInt32()); + Assert.Equal(0, indexJson.GetProperty("summary").GetProperty("errors").GetInt32()); + Assert.Contains( + "OutsideLink.cs", + ReadIndexedPaths(Path.Combine(projectRoot, ".cdidx", "codeindex.db"))); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + DeleteDirectory(outsideRoot); + } + } + + [Fact] + public void Run_DryRunAndFullScan_ClassifyDanglingSymlinkAsWarning_Issue4829() + { + var projectRoot = CreateTempProject(); + try + { + try + { + File.CreateSymbolicLink( + Path.Combine(projectRoot, "Dangling.cs"), + Path.Combine(projectRoot, "Missing.cs")); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var (dryRunExitCode, dryRunJson) = RunAndCaptureJson([ + projectRoot, + "--follow-symlinks", + "all", + "--dry-run", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, dryRunExitCode); + Assert.Equal(0, dryRunJson.GetProperty("errors_total").GetInt32()); + Assert.Equal(1, dryRunJson.GetProperty("warnings_total").GetInt32()); + Assert.Contains( + "dangling symlink", + Assert.Single(dryRunJson.GetProperty("warnings").EnumerateArray()) + .GetProperty("message") + .GetString(), + StringComparison.OrdinalIgnoreCase); + + var (indexExitCode, indexJson) = RunAndCaptureJson([ + projectRoot, + "--follow-symlinks", + "all", + "--json", + ]); + + Assert.Equal(CommandExitCodes.Success, indexExitCode); + Assert.Equal(0, indexJson.GetProperty("summary").GetProperty("errors").GetInt32()); + Assert.Equal(1, indexJson.GetProperty("summary").GetProperty("warnings").GetInt32()); + Assert.Contains( + "dangling symlink", + Assert.Single(indexJson.GetProperty("warnings").EnumerateArray()) + .GetProperty("message") + .GetString(), + StringComparison.OrdinalIgnoreCase); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_DryRun_WithFiles_AllowsDeletedIndexedProjectPath_4471() { diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs index ebdfb15ee..cd1321729 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerFullScanTests.cs @@ -1738,6 +1738,73 @@ public void Run_FullScan_InitialSqlReadFailureKeepsSqlReadinessPartialUntilClean } } + [Fact] + public void Run_FullScan_RetargetedExternalFileLinkFailsWorkspaceValidation_Issue4829() + { + var projectRoot = CreateTempProject(); + var outsideRoot = CreateTempProject(); + var previousContentLoadHook = IndexCommandRunner.FullScanFileContentLoadForTesting; + var retargeted = false; + try + { + var firstTarget = Path.Combine(outsideRoot, "First.cs"); + var secondTarget = Path.Combine(outsideRoot, "Second.cs"); + var linkPath = Path.Combine(projectRoot, "ExternalLink.cs"); + const string source = "public class External4829 { }\n"; + File.WriteAllText(firstTarget, source); + File.WriteAllText(secondTarget, source); + var sharedModifiedUtc = DateTime.UtcNow.AddMinutes(-5); + File.SetLastWriteTimeUtc(firstTarget, sharedModifiedUtc); + File.SetLastWriteTimeUtc(secondTarget, sharedModifiedUtc); + try + { + File.CreateSymbolicLink(linkPath, firstTarget); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + IndexCommandRunner.FullScanFileContentLoadForTesting = path => + { + if (retargeted || path != "ExternalLink.cs") + return; + + File.Delete(linkPath); + File.CreateSymbolicLink(linkPath, secondTarget); + retargeted = true; + }; + + var (exitCode, json) = RunAndCaptureJson([ + projectRoot, + "--follow-symlinks", + "all", + "--parallelism", + "1", + "--json", + "--quiet", + ]); + + Assert.True(retargeted); + Assert.Equal(CommandExitCodes.PartialResult, exitCode); + Assert.Equal("partial", json.GetProperty("status").GetString()); + Assert.Contains( + json.GetProperty("file_errors").EnumerateArray(), + error => error.GetProperty("file").GetString() == "ExternalLink.cs" + && error.GetProperty("phase").GetString() == "csharp_workspace_validation"); + Assert.DoesNotContain( + "ExternalLink.cs", + ReadIndexedPaths(Path.Combine(projectRoot, ".cdidx", "codeindex.db"))); + } + finally + { + IndexCommandRunner.FullScanFileContentLoadForTesting = previousContentLoadHook; + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + DeleteDirectory(outsideRoot); + } + } + [Theory] [InlineData("csharp")] [InlineData("sql")] From c00802a32fd94b1c3d57fa5d47c4a05bf06973c4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 10:54:10 +0900 Subject: [PATCH 2/4] Address symlink review regressions (#4829) --- changelog.d/unreleased/4829.fixed.md | 4 + .../IndexCommandRunner.FullScan.Targets.cs | 3 +- ...IndexCommandRunner.Update.CSharpTargets.cs | 3 +- .../Indexer/CSharpStaticInterfacePrepass.cs | 35 ++++-- .../Scanning/FileContentLoader.RawBytes.cs | 8 +- .../Indexer/Scanning/FileContentLoader.cs | 9 +- .../Scanning/FileIndexer.PathComparison.cs | 114 +++++++++++++++++- src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 5 +- .../Mcp/McpToolHandlers.Indexing.Execution.cs | 3 +- .../FileIndexerContentLoadingTests.cs | 34 ++++++ 10 files changed, 199 insertions(+), 19 deletions(-) diff --git a/changelog.d/unreleased/4829.fixed.md b/changelog.d/unreleased/4829.fixed.md index 2cd5455f0..a9e0c0565 100644 --- a/changelog.d/unreleased/4829.fixed.md +++ b/changelog.d/unreleased/4829.fixed.md @@ -3,13 +3,17 @@ category: fixed issues: - 4829 affected: + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs - src/CodeIndex/Indexer/Scanning/FileIndexer.PathFiltering.cs - src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs - src/CodeIndex/Indexer/Scanning/FileContentLoader.cs - src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs - src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs + - src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs + - src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs - src/CodeIndex/Cli/IndexCommandRunner.DryRun.cs - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs --- ## English diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs index 206dc36eb..5d172f738 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.Targets.cs @@ -44,7 +44,8 @@ private static FullScanTargetPreparation PrepareFullScanTargets( indexedTarget.DisplayRelativePath, indexedTarget.IndexPath, indexedTarget.Language, - indexedTarget.GeneratedExtractionSuppressed)); + indexedTarget.GeneratedExtractionSuppressed, + indexer.ResolvesSymlinkTargets)); } return new FullScanTargetPreparation(fileTargets, csharpPrepassTargets); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs index 6054bead0..d1011bda2 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Update.CSharpTargets.cs @@ -66,7 +66,8 @@ void RememberExistingCSharpTransition(string indexPath) updateTarget.RelativePath, updateTarget.DisplayRelativePath, updateTarget.IndexPath, - language); + language, + ResolveSymlinkTargets: indexer.ResolvesSymlinkTargets); targets.Add(target with { GeneratedExtractionSuppressed = diff --git a/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs b/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs index 558ef0013..3d9aaef63 100644 --- a/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs +++ b/src/CodeIndex/Indexer/CSharpStaticInterfacePrepass.cs @@ -656,7 +656,9 @@ internal static bool TryCaptureFileStatSnapshots( try { validateTarget?.Invoke(target); - var resolvedPath = FileIndexer.ResolveFileReadPath(target.FilePath); + var resolvedPath = ResolveFileStatPath( + target.FilePath, + target.ResolveSymlinkTargets); var info = new FileInfo(LongPath.EnsureWindowsPrefix(resolvedPath)); info.Refresh(); if (!info.Exists) @@ -668,7 +670,8 @@ internal static bool TryCaptureFileStatSnapshots( snapshots[target.IndexPath] = new FileStatSnapshot( info.Length, info.LastWriteTimeUtc, - resolvedPath); + resolvedPath, + target.ResolveSymlinkTargets); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException @@ -729,11 +732,18 @@ internal static bool TryValidateFileStatSnapshots( try { validateTarget?.Invoke(target); - var resolvedPath = FileIndexer.ResolveFileReadPath(target.FilePath); + if (!snapshots.TryGetValue(target.IndexPath, out var snapshot)) + { + changedPath = target.DisplayRelativePath; + return false; + } + + var resolvedPath = ResolveFileStatPath( + target.FilePath, + snapshot.ResolveSymlinkTargets); var info = new FileInfo(LongPath.EnsureWindowsPrefix(resolvedPath)); info.Refresh(); if (!info.Exists - || !snapshots.TryGetValue(target.IndexPath, out var snapshot) || !FileIndexer.FileReadPathsEqual(resolvedPath, snapshot.ResolvedPath) || info.Length != snapshot.Size || info.LastWriteTimeUtc != snapshot.ModifiedUtc) @@ -779,7 +789,9 @@ internal static bool TryValidateLoadedFileStatSnapshot( try { validatePath?.Invoke(filePath); - var resolvedPath = FileIndexer.ResolveFileReadPath(filePath); + var resolvedPath = ResolveFileStatPath( + filePath, + snapshot.ResolveSymlinkTargets); var info = new FileInfo(LongPath.EnsureWindowsPrefix(resolvedPath)); info.Refresh(); if (!info.Exists @@ -804,6 +816,13 @@ or NotSupportedException return true; } + private static string ResolveFileStatPath( + string filePath, + bool resolveSymlinkTargets) + => resolveSymlinkTargets + ? FileIndexer.ResolveFileReadPath(filePath) + : Path.GetFullPath(filePath); + internal static bool TryCaptureDirectoryStatSnapshots( IEnumerable directories, out Dictionary snapshots, @@ -945,7 +964,8 @@ internal readonly record struct FileTarget( string DisplayRelativePath, string IndexPath, string? Language, - bool? GeneratedExtractionSuppressed = null) + bool? GeneratedExtractionSuppressed = null, + bool ResolveSymlinkTargets = false) { public static FileTarget CreateFromPath(string projectRoot, string path) { @@ -970,7 +990,8 @@ public static FileTarget Create(string projectRoot, string filePath, string? lan internal readonly record struct FileStatSnapshot( long Size, DateTime ModifiedUtc, - string ResolvedPath); + string ResolvedPath, + bool ResolveSymlinkTargets = false); internal readonly record struct DirectoryStatSnapshot(DateTime ModifiedUtc); diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs index 26a456108..59f08757e 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs @@ -28,12 +28,12 @@ internal sealed partial class FileContentLoader byte[] bytes; long sizeBytes; DateTime modifiedUtc; - var readPath = FileIndexer.ResolveFileReadPath(absolutePath); + var readPath = _resolveFileReadPath(absolutePath); var ioPath = LongPath.EnsureWindowsPrefix(readPath); for (var attempt = 0; ; attempt++) { var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); - using (var stream = _openReadForIndexContent(readPath)) + using (var stream = _openReadForIndexContent(absolutePath)) { var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) @@ -63,13 +63,13 @@ internal bool RawByteChunksMayMatch( RawByteChunkPredicate chunkPredicate, CancellationToken cancellationToken) { - var readPath = FileIndexer.ResolveFileReadPath(absolutePath); + var readPath = _resolveFileReadPath(absolutePath); var ioPath = LongPath.EnsureWindowsPrefix(readPath); for (var attempt = 0; ; attempt++) { var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); bool matched; - using (var stream = _openReadForIndexContent(readPath)) + using (var stream = _openReadForIndexContent(absolutePath)) { var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs index c41951272..08db0653b 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs @@ -4,10 +4,13 @@ namespace CodeIndex.Indexer; internal sealed partial class FileContentLoader( long maxFileSizeBytes, - Func? openReadForIndexContent = null) + Func? openReadForIndexContent = null, + Func? resolveFileReadPath = null) { private readonly Func _openReadForIndexContent = openReadForIndexContent ?? BoundedFile.OpenReadForIndexContent; + private readonly Func _resolveFileReadPath = + resolveFileReadPath ?? Path.GetFullPath; private const int GitLfsPointerMaxBytes = 1024; private static ReadOnlySpan GitLfsPointerPrefix => "version https://git-lfs.github.com/spec/v1"u8; private static ReadOnlySpan GitLfsExtensionPrefix => "ext-"u8; @@ -100,13 +103,13 @@ internal string LoadNormalizedContentForPrepass( bool retryOnMutation, CancellationToken cancellationToken) { - var readPath = FileIndexer.ResolveFileReadPath(absolutePath); + var readPath = _resolveFileReadPath(absolutePath); var ioPath = LongPath.EnsureWindowsPrefix(readPath); cancellationToken.ThrowIfCancellationRequested(); var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); byte[]? bytes = null; bool lengthChanged; - using (var stream = _openReadForIndexContent(readPath)) + using (var stream = _openReadForIndexContent(absolutePath)) { var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs index 7bc19cb36..2822e2e97 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs @@ -1,3 +1,7 @@ +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + namespace CodeIndex.Indexer; public partial class FileIndexer @@ -22,13 +26,108 @@ private static bool IsLexicalPathEqualOrParent(string candidateParent, string ca } internal static string ResolveFileReadPath(string path) - => NormalizePathForComparison(path); + { + var fullPath = Path.GetFullPath(path); + return TryResolveNativeFinalFilePath(fullPath, out var resolvedPath) + ? Path.TrimEndingDirectorySeparator(resolvedPath) + : NormalizePathForComparison(fullPath); + } + + internal bool ResolvesSymlinkTargets + => _symlinkPolicy != SymlinkPolicy.None; internal static bool FileReadPathsEqual(string left, string right) => CodeIndex.Cli.PathCasing.PathsEqual( Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), Path.TrimEndingDirectorySeparator(Path.GetFullPath(right))); + private static bool TryResolveNativeFinalFilePath( + string fullPath, + out string resolvedPath) + { + resolvedPath = string.Empty; + if (OperatingSystem.IsWindows()) + return TryResolveWindowsFinalFilePath(fullPath, out resolvedPath); + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + return false; + + IntPtr pointer = IntPtr.Zero; + try + { + pointer = UnixRealPath(fullPath, IntPtr.Zero); + if (pointer == IntPtr.Zero) + return false; + + var value = Marshal.PtrToStringUTF8(pointer); + if (string.IsNullOrEmpty(value)) + return false; + + resolvedPath = Path.GetFullPath(value); + return true; + } + catch (Exception ex) when (ex is DllNotFoundException + or EntryPointNotFoundException + or ArgumentException + or IOException + or NotSupportedException + or PathTooLongException) + { + return false; + } + finally + { + if (pointer != IntPtr.Zero) + UnixFree(pointer); + } + } + + private static bool TryResolveWindowsFinalFilePath( + string fullPath, + out string resolvedPath) + { + resolvedPath = string.Empty; + try + { + using var handle = File.OpenHandle( + LongPath.EnsureWindowsPrefix(fullPath), + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + var capacity = 512; + while (capacity <= short.MaxValue) + { + var buffer = new StringBuilder(capacity); + var length = GetFinalPathNameByHandle( + handle, + buffer, + (uint)buffer.Capacity, + flags: 0); + if (length == 0) + return false; + if (length < buffer.Capacity) + { + resolvedPath = Path.GetFullPath( + LongPath.RemoveWindowsPrefix(buffer.ToString())); + return true; + } + + capacity = checked((int)length + 1); + } + } + catch (Exception ex) when (ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or PathTooLongException + or OverflowException + or DllNotFoundException + or EntryPointNotFoundException) + { + } + + return false; + } + private static string NormalizePathForComparison(string path) { var fullPath = Path.GetFullPath(path); @@ -84,6 +183,19 @@ private static string ResolvePathComparisonSegment(string fullPath) return fullPath; } + [DllImport("libc", EntryPoint = "realpath", SetLastError = true)] + private static extern IntPtr UnixRealPath(string path, IntPtr resolvedPath); + + [DllImport("libc", EntryPoint = "free")] + private static extern void UnixFree(IntPtr pointer); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle handle, + StringBuilder path, + uint pathLength, + uint flags); + private string ToRelativePath(string absolutePath) { if (TryGetRelativePathFromProjectRootPrefix(absolutePath, out var fastRelativePath)) diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index e71871820..7f65d344f 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -241,8 +241,11 @@ internal FileIndexer( _nestedGitRepositoryCache = new Dictionary(StringComparer.Ordinal); _maxFileSizeBytes = ResolveMaxFileSizeBytes(maxFileSizeBytes); _openReadForIndexContent = openReadForIndexContent ?? BoundedFile.OpenReadForIndexContent; - _contentLoader = new FileContentLoader(_maxFileSizeBytes, _openReadForIndexContent); _symlinkPolicy = symlinkPolicy; + _contentLoader = new FileContentLoader( + _maxFileSizeBytes, + _openReadForIndexContent, + symlinkPolicy == SymlinkPolicy.None ? null : ResolveFileReadPath); _pathAccessValidator = pathAccessValidator; _bindConfigurationReadsToFileSystemIdentity = bindConfigurationReadsToFileSystemIdentity; _maxDanglingFileSystemEntryScanCandidates = Math.Max( diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs index 95bb0d473..71109e320 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Indexing.Execution.cs @@ -355,7 +355,8 @@ static string FormatDiagnosticPath(string projectRoot, string path) target = target with { GeneratedExtractionSuppressed = hasGeneratedCodeExtractionSuppressionPatterns - && indexer.IsGeneratedCodeExtractionSuppressed(target.IndexPath) + && indexer.IsGeneratedCodeExtractionSuppressed(target.IndexPath), + ResolveSymlinkTargets = indexer.ResolvesSymlinkTargets }; fileTargets[i] = target; if (language == "csharp") diff --git a/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs b/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs index 3e9892d7f..f76dff262 100644 --- a/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs @@ -127,6 +127,40 @@ public void FileContentLoader_Load_AllowsConcurrentWriterShare_Issue4078() } } + [Fact] + public void FileContentLoader_Load_PreservesLexicalPathForAuthorizedOpen_Issue4829() + { + var tempDir = TestProjectHelper.CreateTempProject("codeindex_loader_lexical_path"); + try + { + var lexicalPath = Path.Combine(tempDir, "linked.cs"); + var resolvedPath = Path.Combine(tempDir, "target.cs"); + File.WriteAllText(resolvedPath, "class Target {}\n"); + string? openedPath = null; + var loader = new FileContentLoader( + FileIndexer.DefaultMaxFileSizeBytes, + path => + { + openedPath = path; + return BoundedFile.OpenReadForIndexContent(resolvedPath); + }, + _ => resolvedPath); + + var loaded = loader.Load( + lexicalPath, + "linked.cs", + "linked.cs", + CancellationToken.None); + + Assert.Equal(lexicalPath, openedPath); + Assert.Equal("class Target {}\n", loaded.Content); + } + finally + { + TestProjectHelper.DeleteDirectory(tempDir); + } + } + [Fact] public void FileContentLoader_Load_CarriesConflictMarkerLine() { From f67cdc0dcb94687926d24fcd2652712068c95336 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 11:33:00 +0900 Subject: [PATCH 3/4] Bind symlink reads to opened identity (#4829) --- .../Scanning/FileContentLoader.RawBytes.cs | 50 ++++++++++++++--- .../Indexer/Scanning/FileContentLoader.cs | 17 ++++-- .../Scanning/FileIndexer.PathComparison.cs | 9 +++ src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 5 +- .../FileIndexerContentLoadingTests.cs | 56 +++++++++++++++++++ 5 files changed, 123 insertions(+), 14 deletions(-) diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs index 59f08757e..a2065d1e2 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs @@ -29,12 +29,12 @@ internal sealed partial class FileContentLoader long sizeBytes; DateTime modifiedUtc; var readPath = _resolveFileReadPath(absolutePath); - var ioPath = LongPath.EnsureWindowsPrefix(readPath); for (var attempt = 0; ; attempt++) { - var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); - using (var stream = _openReadForIndexContent(absolutePath)) + DateTime modifiedBeforeRead; + using (var stream = OpenValidatedReadStream(absolutePath, readPath)) { + modifiedBeforeRead = File.GetLastWriteTimeUtc(stream.SafeFileHandle); var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) throw new FileIndexer.FileTooLargeSkippedException( @@ -48,8 +48,8 @@ internal sealed partial class FileContentLoader initialLength, normalizedRelativePath, cancellationToken); + modifiedUtc = File.GetLastWriteTimeUtc(stream.SafeFileHandle); } - modifiedUtc = File.GetLastWriteTimeUtc(ioPath); if (modifiedUtc == modifiedBeforeRead || attempt > 0) break; } @@ -64,13 +64,14 @@ internal bool RawByteChunksMayMatch( CancellationToken cancellationToken) { var readPath = _resolveFileReadPath(absolutePath); - var ioPath = LongPath.EnsureWindowsPrefix(readPath); for (var attempt = 0; ; attempt++) { - var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); + DateTime modifiedBeforeRead; + DateTime modifiedUtc; bool matched; - using (var stream = _openReadForIndexContent(absolutePath)) + using (var stream = OpenValidatedReadStream(absolutePath, readPath)) { + modifiedBeforeRead = File.GetLastWriteTimeUtc(stream.SafeFileHandle); var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) throw new FileIndexer.FileTooLargeSkippedException( @@ -85,17 +86,50 @@ internal bool RawByteChunksMayMatch( normalizedRelativePath, chunkPredicate, cancellationToken); + modifiedUtc = File.GetLastWriteTimeUtc(stream.SafeFileHandle); } if (matched) return true; - var modifiedUtc = File.GetLastWriteTimeUtc(ioPath); if (modifiedUtc == modifiedBeforeRead || attempt > 0) return false; } } + private FileStream OpenValidatedReadStream(string absolutePath, string expectedReadPath) + { + _validateResolvedFileReadPath?.Invoke(expectedReadPath); + FileIndexer.FileIdentity expectedIdentity = default; + if (_bindReadToFileSystemIdentity + && !FileIndexer.TryGetFileIdentity(expectedReadPath, out expectedIdentity)) + { + throw new IOException( + "Failed to capture the filesystem identity of a symlink target before opening it."); + } + + var stream = _openReadForIndexContent(absolutePath); + try + { + if (!_bindReadToFileSystemIdentity) + return stream; + + if (!FileIndexer.TryGetFileIdentity(stream.SafeFileHandle, out var openedIdentity) + || openedIdentity != expectedIdentity) + { + throw new IOException( + "File symlink target identity changed while it was opened; rerun indexing."); + } + + return stream; + } + catch + { + stream.Dispose(); + throw; + } + } + private (byte[] Bytes, long SizeBytes) ReadStreamBytesWithKnownInitialLength( FileStream stream, long initialLength, diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs index 08db0653b..2d9252fe1 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs @@ -5,12 +5,18 @@ namespace CodeIndex.Indexer; internal sealed partial class FileContentLoader( long maxFileSizeBytes, Func? openReadForIndexContent = null, - Func? resolveFileReadPath = null) + Func? resolveFileReadPath = null, + bool bindReadToFileSystemIdentity = false, + Action? validateResolvedFileReadPath = null) { private readonly Func _openReadForIndexContent = openReadForIndexContent ?? BoundedFile.OpenReadForIndexContent; private readonly Func _resolveFileReadPath = resolveFileReadPath ?? Path.GetFullPath; + private readonly bool _bindReadToFileSystemIdentity = + bindReadToFileSystemIdentity; + private readonly Action? _validateResolvedFileReadPath = + validateResolvedFileReadPath; private const int GitLfsPointerMaxBytes = 1024; private static ReadOnlySpan GitLfsPointerPrefix => "version https://git-lfs.github.com/spec/v1"u8; private static ReadOnlySpan GitLfsExtensionPrefix => "ext-"u8; @@ -104,13 +110,14 @@ internal string LoadNormalizedContentForPrepass( CancellationToken cancellationToken) { var readPath = _resolveFileReadPath(absolutePath); - var ioPath = LongPath.EnsureWindowsPrefix(readPath); cancellationToken.ThrowIfCancellationRequested(); - var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); byte[]? bytes = null; bool lengthChanged; - using (var stream = _openReadForIndexContent(absolutePath)) + DateTime modifiedBeforeRead; + DateTime modifiedAfterRead; + using (var stream = OpenValidatedReadStream(absolutePath, readPath)) { + modifiedBeforeRead = File.GetLastWriteTimeUtc(stream.SafeFileHandle); var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) throw new FileIndexer.FileTooLargeSkippedException( @@ -138,9 +145,9 @@ internal string LoadNormalizedContentForPrepass( } lengthChanged = stream.Length != initialLength; + modifiedAfterRead = File.GetLastWriteTimeUtc(stream.SafeFileHandle); } - var modifiedAfterRead = File.GetLastWriteTimeUtc(ioPath); if (retryOnMutation && (modifiedAfterRead != modifiedBeforeRead || lengthChanged)) return (null, RequiresRetry: true); if (bytes is null || IsGitLfsPointer(bytes)) diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs index 2822e2e97..368f635c5 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.PathComparison.cs @@ -128,6 +128,15 @@ or DllNotFoundException return false; } + private void ValidateResolvedFileReadPath(string resolvedReadPath) + { + if (!IsPathEqualOrParent(_projectRoot, resolvedReadPath)) + { + throw new IOException( + "File symlink target resolved outside the project root before opening; rerun indexing."); + } + } + private static string NormalizePathForComparison(string path) { var fullPath = Path.GetFullPath(path); diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index 7f65d344f..612fa49b4 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -245,7 +245,10 @@ internal FileIndexer( _contentLoader = new FileContentLoader( _maxFileSizeBytes, _openReadForIndexContent, - symlinkPolicy == SymlinkPolicy.None ? null : ResolveFileReadPath); + symlinkPolicy == SymlinkPolicy.None ? null : ResolveFileReadPath, + bindReadToFileSystemIdentity: symlinkPolicy != SymlinkPolicy.None, + validateResolvedFileReadPath: + symlinkPolicy == SymlinkPolicy.Internal ? ValidateResolvedFileReadPath : null); _pathAccessValidator = pathAccessValidator; _bindConfigurationReadsToFileSystemIdentity = bindConfigurationReadsToFileSystemIdentity; _maxDanglingFileSystemEntryScanCandidates = Math.Max( diff --git a/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs b/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs index f76dff262..e82c13c3f 100644 --- a/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs @@ -161,6 +161,62 @@ public void FileContentLoader_Load_PreservesLexicalPathForAuthorizedOpen_Issue48 } } + [Fact] + public void FileContentLoader_Load_RejectsInternalLinkRetargetedDuringOpen_Issue4829() + { + var projectRoot = TestProjectHelper.CreateTempProject("codeindex_loader_internal_retarget"); + var externalRoot = TestProjectHelper.CreateTempProject("codeindex_loader_external_retarget"); + try + { + var internalTarget = Path.Combine(projectRoot, "inside.py"); + var externalTarget = Path.Combine(externalRoot, "outside.py"); + var linkPath = Path.Combine(projectRoot, "alias.py"); + File.WriteAllText(internalTarget, "inside\n"); + File.WriteAllText(externalTarget, "secret\n"); + var sharedModifiedUtc = DateTime.UtcNow.AddMinutes(-1); + File.SetLastWriteTimeUtc(internalTarget, sharedModifiedUtc); + File.SetLastWriteTimeUtc(externalTarget, sharedModifiedUtc); + try + { + File.CreateSymbolicLink(linkPath, internalTarget); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + var retargeted = false; + var indexer = new FileIndexer( + projectRoot, + ignoreCase: false, + ignoreRuleRoot: null, + maxFileSizeBytes: null, + directoryIgnoreCaseProbe: null, + symlinkPolicy: FileIndexer.SymlinkPolicy.Internal, + openReadForIndexContent: path => + { + if (!retargeted) + { + File.Delete(path); + File.CreateSymbolicLink(path, externalTarget); + retargeted = true; + } + + return BoundedFile.OpenReadForIndexContent(path); + }); + + var exception = Assert.Throws(() => indexer.BuildRecord(linkPath)); + + Assert.True(retargeted); + Assert.Contains("identity changed", exception.Message, StringComparison.OrdinalIgnoreCase); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(externalRoot); + } + } + [Fact] public void FileContentLoader_Load_CarriesConflictMarkerLine() { From 9df321cb1bb8b5329e98dfb6277b08ba19d0a390 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 12:12:02 +0900 Subject: [PATCH 4/4] Preserve atomic replacement retry (#4829) --- .../Scanning/FileContentLoader.RawBytes.cs | 21 +++++++++++++++---- .../Indexer/Scanning/FileContentLoader.cs | 5 ++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs index a2065d1e2..876e4d101 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs @@ -28,10 +28,11 @@ internal sealed partial class FileContentLoader byte[] bytes; long sizeBytes; DateTime modifiedUtc; - var readPath = _resolveFileReadPath(absolutePath); for (var attempt = 0; ; attempt++) { + var readPath = _resolveFileReadPath(absolutePath); DateTime modifiedBeforeRead; + bool pathIdentityChanged; using (var stream = OpenValidatedReadStream(absolutePath, readPath)) { modifiedBeforeRead = File.GetLastWriteTimeUtc(stream.SafeFileHandle); @@ -49,8 +50,9 @@ internal sealed partial class FileContentLoader normalizedRelativePath, cancellationToken); modifiedUtc = File.GetLastWriteTimeUtc(stream.SafeFileHandle); + pathIdentityChanged = ReadPathIdentityChanged(absolutePath, stream); } - if (modifiedUtc == modifiedBeforeRead || attempt > 0) + if ((modifiedUtc == modifiedBeforeRead && !pathIdentityChanged) || attempt > 0) break; } @@ -63,11 +65,12 @@ internal bool RawByteChunksMayMatch( RawByteChunkPredicate chunkPredicate, CancellationToken cancellationToken) { - var readPath = _resolveFileReadPath(absolutePath); for (var attempt = 0; ; attempt++) { + var readPath = _resolveFileReadPath(absolutePath); DateTime modifiedBeforeRead; DateTime modifiedUtc; + bool pathIdentityChanged; bool matched; using (var stream = OpenValidatedReadStream(absolutePath, readPath)) { @@ -87,12 +90,13 @@ internal bool RawByteChunksMayMatch( chunkPredicate, cancellationToken); modifiedUtc = File.GetLastWriteTimeUtc(stream.SafeFileHandle); + pathIdentityChanged = ReadPathIdentityChanged(absolutePath, stream); } if (matched) return true; - if (modifiedUtc == modifiedBeforeRead || attempt > 0) + if ((modifiedUtc == modifiedBeforeRead && !pathIdentityChanged) || attempt > 0) return false; } } @@ -130,6 +134,15 @@ private FileStream OpenValidatedReadStream(string absolutePath, string expectedR } } + private static bool ReadPathIdentityChanged(string absolutePath, FileStream stream) + { + if (!FileIndexer.TryGetFileIdentity(stream.SafeFileHandle, out var openedIdentity)) + return false; + + return !FileIndexer.TryGetFileIdentity(absolutePath, out var currentIdentity) + || currentIdentity != openedIdentity; + } + private (byte[] Bytes, long SizeBytes) ReadStreamBytesWithKnownInitialLength( FileStream stream, long initialLength, diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs index 2d9252fe1..ee8ce6049 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.cs @@ -113,6 +113,7 @@ internal string LoadNormalizedContentForPrepass( cancellationToken.ThrowIfCancellationRequested(); byte[]? bytes = null; bool lengthChanged; + bool pathIdentityChanged; DateTime modifiedBeforeRead; DateTime modifiedAfterRead; using (var stream = OpenValidatedReadStream(absolutePath, readPath)) @@ -146,9 +147,11 @@ internal string LoadNormalizedContentForPrepass( lengthChanged = stream.Length != initialLength; modifiedAfterRead = File.GetLastWriteTimeUtc(stream.SafeFileHandle); + pathIdentityChanged = ReadPathIdentityChanged(absolutePath, stream); } - if (retryOnMutation && (modifiedAfterRead != modifiedBeforeRead || lengthChanged)) + if (retryOnMutation + && (modifiedAfterRead != modifiedBeforeRead || lengthChanged || pathIdentityChanged)) return (null, RequiresRetry: true); if (bytes is null || IsGitLfsPointer(bytes)) return (null, RequiresRetry: false);