diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 092c693fd..26e19041c 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2948,7 +2948,28 @@ If every platform candidate is unavailable, the final temp fallback is a hashed per-user `cdidx-u.../logs` directory under the OS temp root. Each candidate is probed with a create/write/delete round trip before the logger commits to it, so read-only state/cache/runtime mounts fall through to the -next candidate instead of losing the first log write. The file name is +next candidate instead of losing the first log write. Repository-configured +`metrics_path` and `global_tool_log_dir` values from `.cdidxrc.json` or +`.cdidx/config.json` use a stricter boundary: every existing component below +the config workspace is rejected when it is a symbolic link, junction, bind or +cross-device mount point, reparse point, device, or dangling link. The boundary +is revalidated before each mutation; on Linux, path and opened-handle mount IDs +from `statx` or the `/proc/self/fdinfo` fallback also reject same-device bind +mounts. On POSIX, directory creation, append, +permission changes, rotation, replacement, and deletion are additionally anchored +to the workspace directory handle with no-follow relative operations. Directory +parents are rebound from that root after mutation-time validation so a +moved ancestor is not reused. Guarded renames fsync their already-open destination +parent before reporting success. On Windows, the guard retains the workspace root's +final physical path so a retargeted root alias is not followed; root-handle-relative +native opens reject reparse points during name resolution, and guarded replacement +and deletion remain handle/root relative. The same +`global_tool_log_dir` guard covers lifecycle logs, file query traces, and the +bounded `last-failure.json` diagnostic. An unsafe value fails config validation +with the bounded `unsafe_output_path` diagnostic and does not create, append, +rotate, replace, delete, or chmod the external target. Explicit CLI and process +environment destinations retain their existing operator-controlled behavior. +The file name is `stderr-YYYYMMDD.log`, timestamps inside the file are ISO-8601 UTC (`yyyy-MM-ddTHH:mm:ss.fffZ`) using invariant culture, and the logger keeps only the newest 30 daily files. `CDIDX_LOG_FORMAT` / `--log-format` switch @@ -6950,7 +6971,26 @@ platform candidate がすべて使えない場合、最後の temp fallback は 配下のユーザー別 hashed `cdidx-u.../logs` ディレクトリです。各 candidate は logger が採用する前に create/write/delete の往復で probe されるため、 read-only な state/cache/runtime mount は最初の log write を失うのではなく -次の candidate へ fall through します。ファイル名は +次の candidate へ fall through します。`.cdidxrc.json` または +`.cdidx/config.json` の repository config に由来する `metrics_path` と +`global_tool_log_dir` には、より厳格な境界を適用します。config workspace +配下の既存 component が symbolic link、junction、bind mount / cross-device mount point、 +reparse point、device、dangling link のいずれかであれば拒否し、各 mutation の +直前にも境界を再検証します。Linux では `statx` または +`/proc/self/fdinfo` fallback から得た path と open 済み handle の mount ID も +比較して同一 device の bind mount を拒否します。POSIX ではさらに directory 作成、 +append、permission 変更、rotation、置換、delete を workspace directory handle 起点の +no-follow relative operation へ固定し、mutation 時の検証後に directory parent を root +から再取得するため、外部へ移動済みの ancestor handle を再利用しません。guarded rename は +成功を返す前に open 済みの destination parent を fsync します。Windows では workspace +root の最終的な物理 path を guard が保持するため、差し替えられた root alias を再追跡しません。 +root handle 相対の native open は name resolution 中の reparse point を拒否し、guarded +replacement / delete も handle / root 相対で実行します。同じ `global_tool_log_dir` guard を lifecycle log、file +query trace、上限付きの `last-failure.json` 診断にも適用します。安全でない値は +上限付きの `unsafe_output_path` 診断で config validation に失敗し、外部 target の +作成、追記、rotation、置換、削除、chmod は行いません。明示的な CLI と process +environment の保存先は、operator が制御する従来の挙動を維持します。 +ファイル名は `stderr-YYYYMMDD.log`、ファイル内 timestamp は invariant culture の ISO-8601 UTC(`yyyy-MM-ddTHH:mm:ss.fffZ`)で、logger は新しい 30 日次 ファイルだけを保持します。`CDIDX_LOG_FORMAT` / `--log-format` は text と diff --git a/USER_GUIDE.md b/USER_GUIDE.md index b0d581692..a2fdbe683 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2706,6 +2706,13 @@ Run `cdidx status --log-path` to print the active log directory without opening ### Project-local configuration file (`.cdidx/config.json` / `.cdidxrc.json`) +Repository-configured `metrics_path` and `global_tool_log_dir` values must remain +inside the config workspace. Existing components below that workspace must not be +symbolic links, junctions, bind or cross-device mount points, reparse points, +devices or special files, or dangling links; unsafe values fail validation with +`unsafe_output_path`. Use the CLI flag or a real environment variable when an +outside destination is intentional. + You can check a `.cdidx/config.json` or `.cdidxrc.json` file into a repository to set per-project defaults instead of relying on shell-profile or CI env vars (#1571). Before a config-dependent command runs, `cdidx` walks upward from the current working directory looking for the first project config file, validates its schema, and materializes recognized keys as scoped environment settings — so every existing env-var consumer picks them up without process-global mutation. Static commands that do not consume project settings (`license`, `--version`, help forms, shell completions, and any command's `--help`) skip config discovery and remain usable even when an unrelated project config is malformed. Discovery stops after checking a directory that contains `.git`, `.hg`, `.svn`, `cdidx.workspace.json`, or `.cdidx-workspace.json`, so a child workspace does not inherit a config file from an unrelated parent. Precedence is **CLI flag > environment variable > config file > built-in default**. A config-file value is applied only when the matching env var is not already set in the process, so a value the user already exported in the shell or CI always wins. Config JSON is bounded to 64 KiB and a conservative nesting depth before schema validation. For config-dependent commands, a malformed file (invalid JSON, unknown key, wrong type, or excessive nesting) is a hard error: cdidx exits `1` with the file path and all detected offending fields. JSON mode returns the versioned command-error envelope with `error_code: "E024_CONFIG_INVALID"` and `category: "configuration"` instead of writing human-only text to stderr. Set `CDIDX_DISABLE_CONFIG_FILE=1` to bypass the file entirely. @@ -6308,6 +6315,13 @@ MCP のレスポンスサイズ上限は、環境変数 override で guard が ### プロジェクト固有の設定ファイル (`.cdidx/config.json` / `.cdidxrc.json`) +repository config の `metrics_path` と `global_tool_log_dir` は config workspace +内に収まる必要があります。workspace 配下の既存 component には symbolic link、 +junction、bind mount または cross-device mount、reparse point、device / special +file、dangling link を含めることはできず、安全でない値は +`unsafe_output_path` で検証に失敗します。意図的に外部の出力先を使う場合は CLI +flag または実際の環境変数を使ってください。 + シェルプロファイルや CI の環境変数に頼らず、プロジェクトごとの既定値を `.cdidx/config.json` または `.cdidxrc.json` ファイルとしてリポジトリにチェックインできます (#1571)。config に依存する command の実行前に、`cdidx` はカレントディレクトリから上方向に最初のプロジェクト設定ファイルを探索し、スキーマを検証してから既知のキーを scoped environment setting として注入します。これにより、process-global な環境変数を変更せずに、既存の環境変数コンシューマが同じ値を受け取れます。プロジェクト設定を使用しない static command(`license`、`--version`、help 形式、shell completion、および各 command の `--help`)は config 探索を省略するため、無関係なプロジェクト設定が不正でも利用できます。探索は `.git`、`.hg`、`.svn`、`cdidx.workspace.json`、`.cdidx-workspace.json` を含むディレクトリを確認した後で停止するため、子 workspace が無関係な親ディレクトリの設定ファイルを継承しません。 優先順位は **CLI フラグ > 環境変数 > 設定ファイル > 組み込み既定値** です。設定ファイル由来の値は、対応する環境変数がプロセスで未設定の場合にのみ適用されるため、シェルや CI で既に export されている値が常に優先されます。設定 JSON はスキーマ検証前に 64 KiB と保守的なネスト深度の上限で検査されます。config に依存する command では、不正なファイル(無効な JSON、未知のキー、型違い、過度なネスト)は hard error として扱われ、cdidx はファイルパスと検出できた該当フィールドすべてを示して終了コード `1` で終了します。JSON mode では human-only text を stderr に書く代わりに、`error_code: "E024_CONFIG_INVALID"` と `category: "configuration"` を持つ versioned command-error envelope を返します。完全にバイパスしたい場合は `CDIDX_DISABLE_CONFIG_FILE=1` を設定してください。 diff --git a/changelog.d/unreleased/5181.security.md b/changelog.d/unreleased/5181.security.md new file mode 100644 index 000000000..86002b3a5 --- /dev/null +++ b/changelog.d/unreleased/5181.security.md @@ -0,0 +1,25 @@ +--- +category: security +issues: + - 5181 +affected: + - src/CodeIndex/Cli/CdidxConfigFile.cs + - src/CodeIndex/Cli/RepositoryOutputPathBoundary.cs + - src/CodeIndex/Cli/MetricsSink.cs + - src/CodeIndex/Cli/GlobalToolLog.cs + - src/CodeIndex/Cli/ProgramRunner.Metrics.cs + - src/CodeIndex/Cli/LastFailureEventStore.cs + - src/CodeIndex/Cli/PrivateLogFile.cs + - tests/CodeIndex.Tests/CdidxConfigFileTests.cs + - tests/CodeIndex.Tests/MetricsSinkTests.cs + - tests/CodeIndex.Tests/GlobalToolLogTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Repository-configured metrics and global-log paths can no longer escape through filesystem aliases (#5181)** — `metrics_path` and `global_tool_log_dir` now reject symbolic-link, junction, bind-mount, cross-device-mount, reparse-point, device/special-file, and dangling-link components below the config workspace. Mutations are revalidated, Linux path/open-handle mount IDs reject same-device bind mounts, and POSIX writes, directory creation, permission changes, rotation, replacement, and deletion are anchored to the workspace directory handle with mutation-time parent rebinding; guarded renames also fsync the destination parent before returning success. Windows guards retain the workspace root's final physical path and use root-handle-relative no-reparse native operations, so retargeting a root alias cannot redirect later mutations. The global-log guard covers query traces and `last-failure.json`, so an untrusted checkout cannot redirect them to an external target. + +## 日本語 + +- **repository config の metrics / global-log path が filesystem alias を経由して外部へ逸脱できないようになりました (#5181)** — `metrics_path` と `global_tool_log_dir` は config workspace 配下の symbolic link、junction、bind mount、cross-device mount、reparse point、device / special file、dangling link を拒否します。mutation 前の再検証に加え、Linux では path / open 済み handle の mount ID で同一 device の bind mount も拒否し、POSIX の書き込み、directory 作成、permission 変更、rotation、置換、削除を mutation 時の parent 再取得を伴う workspace directory handle に固定します。guarded rename は成功を返す前に destination parent を fsync します。Windows guard は workspace root の最終的な物理 path を保持し、root handle 相対の no-reparse native operation を使うため、root alias を差し替えても後続 mutation を外部へリダイレクトできません。global-log guard は query trace と `last-failure.json` にも適用し、信頼できない checkout が外部 target へリダイレクトできないようにしました。 diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index c9c20b14d..39392b902 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -573,7 +573,7 @@ private static bool IsConfigDiscoveryBoundary(DirectoryInfo directory) return false; } - private static string ResolveConfigWorkspaceRoot(string configPath) + internal static string ResolveConfigWorkspaceRoot(string configPath) { var fullConfigPath = Path.GetFullPath(configPath); var configDirectory = Path.GetDirectoryName(fullConfigPath) ?? Path.GetFullPath("."); @@ -1005,45 +1005,24 @@ private static bool TryReadWorkspaceOutputPath(JsonElement element, string key, return false; var workspaceRoot = ResolveConfigWorkspaceRoot(path); - if (!TryResolveWorkspaceOutputPath(raw!, workspaceRoot, out value, out var pathError)) - { - error = pathError; + var destinationIsDirectory = string.Equals(key, "global_tool_log_dir", StringComparison.Ordinal); + if (!RepositoryOutputPathBoundary.TryResolveConfiguredPath( + raw!, + workspaceRoot, + destinationIsDirectory, + out var resolvedPath, + out var pathFailure)) + { + error = pathFailure == "outside_workspace" + ? $"{FormatConfigDiagnosticPrefix(path)} `{key}` must resolve inside the config workspace root `{FormatConfigDiagnosticPath(workspaceRoot)}`." + : pathFailure == RepositoryOutputPathBoundary.UnsafeReason + ? $"{FormatConfigDiagnosticPrefix(path)} `{key}` is unsafe ({RepositoryOutputPathBoundary.UnsafeReason}); symbolic links, junctions, bind or cross-device mount points, reparse points, devices, and dangling links are not allowed below the config workspace root." + : $"{FormatConfigDiagnosticPrefix(path)} `{key}` path is invalid (invalid_path)."; return false; } + value = resolvedPath; return true; - - bool TryResolveWorkspaceOutputPath(string rawPath, string root, out string? resolved, out string? pathError) - { - resolved = null; - pathError = null; - try - { - var normalizedRoot = PathCasing.NormalizeBoundaryPath(root); - var fullPath = Path.IsPathRooted(rawPath) - ? Path.GetFullPath(rawPath) - : Path.GetFullPath(Path.Combine(normalizedRoot, rawPath)); - var normalizedPath = PathCasing.NormalizeBoundaryPath(fullPath); - - if (!PathCasing.IsPathEqualOrParent(normalizedRoot, normalizedPath)) - { - pathError = $"{FormatConfigDiagnosticPrefix(path)} `{key}` must resolve inside the config workspace root `{FormatConfigDiagnosticPath(normalizedRoot)}`."; - return false; - } - - resolved = fullPath; - return true; - } - catch (Exception ex) when (ex is ArgumentException - or IOException - or NotSupportedException - or PathTooLongException - or UnauthorizedAccessException) - { - pathError = $"{FormatConfigDiagnosticPrefix(path)} `{key}` path is invalid (invalid_path)."; - return false; - } - } } private static bool TryReadStringArray(JsonElement element, string key, string path, out string[]? value, out string? error) diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index 45f2b0e35..dd435fefa 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -32,6 +32,7 @@ internal static class GlobalToolLog private const int PrivateLogDiagnosticEmitLimit = 16; internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; private static readonly AsyncLocal CurrentSession = new(); + internal sealed record LogDirectorySelection(string Path, RepositoryOutputPathGuard? Boundary); internal static IDisposable? TryStart(string[] args, string appVersion) => TryStart(args, appVersion, createWriter: null, afterWriterCreated: null); @@ -56,15 +57,20 @@ internal static class GlobalToolLog return null; var privateLogDiagnostics = new List(); - var logDirectory = ResolveLogDirectory(); - Directory.CreateDirectory(logDirectory); - HardenLogFiles(logDirectory, privateLogDiagnostics.Add); + var selection = ResolveLogDirectorySelection(requireWritableCandidate: true); + var logDirectory = selection.Path; + var boundary = selection.Boundary; + if (boundary is null) + Directory.CreateDirectory(logDirectory); + else + boundary.CreateSensitiveDestinationDirectory(); + HardenLogFiles(logDirectory, privateLogDiagnostics.Add, boundary); var options = LogOptions.FromEnvironment(); var logPath = ResolveLogPath(logDirectory, options); - writer = createWriter?.Invoke(logPath) ?? CreateLogWriter(logPath); + writer = createWriter?.Invoke(logPath) ?? CreateLogWriter(logPath, boundary); afterWriterCreated?.Invoke(); - SetLogFilePermissions(logPath, privateLogDiagnostics.Add); - PruneOldLogs(logDirectory, options.RetainCount, privateLogDiagnostics.Add); + SetLogFilePermissions(logPath, privateLogDiagnostics.Add, boundary); + PruneOldLogs(logDirectory, options.RetainCount, privateLogDiagnostics.Add, boundary); var session = new Session(writer, logPath, options.Format); writer = null; @@ -78,6 +84,15 @@ internal static class GlobalToolLog WritePrivateLogDiagnostics(session, privateLogDiagnostics); return session; } + catch (RepositoryOutputPathBoundaryException) + { + writer?.Dispose(); + CurrentSession.Value = null; + CommandErrorWriter.WriteWarning( + "persistent log disabled; repository-configured `global_tool_log_dir` is unsafe " + + $"({RepositoryOutputPathBoundary.UnsafeReason})."); + return null; + } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { writer?.Dispose(); @@ -86,8 +101,8 @@ internal static class GlobalToolLog } } - private static StreamWriter CreateLogWriter(string logPath) => - PrivateLogFile.OpenAppendText(logPath); + private static StreamWriter CreateLogWriter(string logPath, RepositoryOutputPathGuard? boundary) => + PrivateLogFile.OpenAppendText(logPath, boundary); internal static void Info(string message) => CurrentSession.Value?.Write("INFO", message); @@ -275,23 +290,43 @@ private static bool ContainsPathSegments(string path, string[] expectedSegments, internal static string ResolveLogDirectoryForStatus() => ResolveLogDirectory(); - private static string ResolveLogDirectory() => ResolveLogDirectory(requireWritableCandidate: true); + internal static LogDirectorySelection ResolveLogDirectorySelectionForRepositoryWrite() + => ResolveLogDirectorySelection(requireWritableCandidate: true); + + private static string ResolveLogDirectory() => ResolveLogDirectorySelection(requireWritableCandidate: true).Path; internal static string ResolveLogDirectoryWithoutWriteProbeForTesting() - => ResolveLogDirectory(requireWritableCandidate: false); + => ResolveLogDirectorySelection(requireWritableCandidate: false).Path; - private static string ResolveLogDirectory(bool requireWritableCandidate) + private static LogDirectorySelection ResolveLogDirectorySelection(bool requireWritableCandidate) { + var configuredOverride = CdidxEnvironment.GetEnvironmentVariable("CDIDX_GLOBAL_TOOL_LOG_DIR"); + var configuredSource = CdidxConfigSourceResolver.GetSource("CDIDX_GLOBAL_TOOL_LOG_DIR"); foreach (var candidate in EnumerateLogDirectoryCandidates()) { if (!TryNormalizeLogDirectoryCandidate(candidate, out var fullPath)) continue; - if (!requireWritableCandidate || CanWriteProbe(fullPath)) - return fullPath; + RepositoryOutputPathGuard? boundary = null; + if (!string.IsNullOrWhiteSpace(configuredOverride) + && !string.IsNullOrWhiteSpace(configuredSource) + && string.Equals( + PathCasing.NormalizeBoundaryPath(ExpandUserLogDirectory(configuredOverride)), + PathCasing.NormalizeBoundaryPath(candidate), + PathCasing.ComparisonFor(candidate))) + { + boundary = RepositoryOutputPathBoundary.CreateGuardForConfigSource( + "CDIDX_GLOBAL_TOOL_LOG_DIR", + "global_tool_log_dir", + fullPath, + destinationIsDirectory: true); + } + + if (!requireWritableCandidate || CanWriteProbe(fullPath, boundary)) + return new LogDirectorySelection(fullPath, boundary); } - return ResolveTempFallbackLogDirectory(); + return new LogDirectorySelection(ResolveTempFallbackLogDirectory(), null); } internal static bool TryNormalizeLogDirectoryCandidate(string candidate, out string fullPath) @@ -352,14 +387,34 @@ private static IEnumerable EnumerateLogDirectoryCandidates() private static string ResolveTempFallbackLogDirectory() => DataDirectorySecurity.ResolveSensitiveTempFallbackDirectory("logs"); - private static bool CanWriteProbe(string directory) + private static bool CanWriteProbe(string directory, RepositoryOutputPathGuard? boundary) { try { - DataDirectorySecurity.CreateSensitiveDirectory(directory); + if (boundary is null) + DataDirectorySecurity.CreateSensitiveDirectory(directory); + else + boundary.CreateSensitiveDestinationDirectory(); var probePath = Path.Combine(directory, $".cdidx-write-probe-{Guid.NewGuid():N}.tmp"); + if (boundary is not null) + { + using (PrivateLogFile.OpenAppend(probePath, boundary: boundary)) + { + } + boundary.PrepareMutation("write_probe_delete", probePath); + if (OperatingSystem.IsWindows()) + boundary.DeleteFileWindows(probePath); + else + boundary.DeleteFileUnix(probePath); + boundary.CompleteMutation(probePath); + return true; + } return FileWriteProbe.TryWriteAndDeleteEmptyFile(probePath, Encoding.UTF8); } + catch (RepositoryOutputPathBoundaryException) + { + throw; + } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) { return false; @@ -429,11 +484,17 @@ private static string CreateProcessLogSuffix() private static void PruneOldLogs( string logDirectory, int retainedLogFileCount, - Action? diagnosticSink) + Action? diagnosticSink, + RepositoryOutputPathGuard? boundary) { try { - PrivateLogFile.PruneOldFiles(logDirectory, "stderr-*.log", retainedLogFileCount, diagnosticSink); + PrivateLogFile.PruneOldFiles( + logDirectory, + "stderr-*.log", + retainedLogFileCount, + diagnosticSink, + boundary: boundary); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -441,14 +502,17 @@ private static void PruneOldLogs( } } - private static void HardenLogFiles(string logDirectory, Action? diagnosticSink) + private static void HardenLogFiles( + string logDirectory, + Action? diagnosticSink, + RepositoryOutputPathGuard? boundary) { if (OperatingSystem.IsWindows()) return; try { - PrivateLogFile.HardenExisting(logDirectory, "stderr-*.log", diagnosticSink); + PrivateLogFile.HardenExisting(logDirectory, "stderr-*.log", diagnosticSink, boundary); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -456,14 +520,17 @@ private static void HardenLogFiles(string logDirectory, Action? diagnosticSink) + private static void SetLogFilePermissions( + string logPath, + Action? diagnosticSink, + RepositoryOutputPathGuard? boundary) { if (OperatingSystem.IsWindows()) return; try { - PrivateLogFile.TrySetPrivatePermissions(logPath, diagnosticSink); + PrivateLogFile.TrySetPrivatePermissions(logPath, diagnosticSink, boundary); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { diff --git a/src/CodeIndex/Cli/LastFailureEventStore.cs b/src/CodeIndex/Cli/LastFailureEventStore.cs index 0e4c3a36c..9d765f3fc 100644 --- a/src/CodeIndex/Cli/LastFailureEventStore.cs +++ b/src/CodeIndex/Cli/LastFailureEventStore.cs @@ -73,9 +73,15 @@ internal static bool TryPersist( if (Encoding.UTF8.GetByteCount(json) > MaxEventBytes) return false; - var logDirectory = GlobalToolLog.ResolveLogDirectoryForReport(); - DataDirectorySecurity.CreateSensitiveDirectory(logDirectory); - DataDirectorySecurity.WritePrivateText(Path.Combine(logDirectory, FileName), json + "\n"); + var selection = GlobalToolLog.ResolveLogDirectorySelectionForRepositoryWrite(); + if (selection.Boundary is null) + DataDirectorySecurity.CreateSensitiveDirectory(selection.Path); + else + selection.Boundary.CreateSensitiveDestinationDirectory(); + PrivateLogFile.WritePrivateTextReplacing( + Path.Combine(selection.Path, FileName), + json + "\n", + selection.Boundary); return true; } catch (Exception ex) when (ex is not OutOfMemoryException) diff --git a/src/CodeIndex/Cli/MetricsSink.cs b/src/CodeIndex/Cli/MetricsSink.cs index 96eaef7bd..37541d086 100644 --- a/src/CodeIndex/Cli/MetricsSink.cs +++ b/src/CodeIndex/Cli/MetricsSink.cs @@ -63,19 +63,45 @@ internal static class MetricsSink try { var fullPath = Path.GetFullPath(path); - DataDirectorySecurity.CreateSensitiveParentDirectoryForFile(fullPath); + var boundary = string.IsNullOrWhiteSpace(explicitPath) + ? RepositoryOutputPathBoundary.CreateGuardForConfigSource( + EnvVarName, + "metrics_path", + fullPath, + destinationIsDirectory: false) + : null; + if (boundary is null) + DataDirectorySecurity.CreateSensitiveParentDirectoryForFile(fullPath); + else + boundary.CreateSensitiveDestinationDirectory(); long bytesWritten; - using (var probe = PrivateLogFile.OpenAppend(fullPath, FileShare.ReadWrite)) + using (var probe = PrivateLogFile.OpenAppend(fullPath, FileShare.ReadWrite, boundary)) { bytesWritten = probe.Length; } - PrivateLogFile.TrySetPrivatePermissions(fullPath); - - var session = new Session(fullPath, maxBytes, bytesWritten, warningSink, queueCapacity, retryDelay, disposeWriterTimeout); + PrivateLogFile.TrySetPrivatePermissions(fullPath, boundary: boundary); + + var session = new Session( + fullPath, + maxBytes, + bytesWritten, + warningSink, + queueCapacity, + retryDelay, + disposeWriterTimeout, + boundary); CurrentSession.Value = session; return session; } + catch (RepositoryOutputPathBoundaryException) + { + warningSink?.Invoke( + "metrics output disabled; repository-configured `metrics_path` is unsafe " + + $"({RepositoryOutputPathBoundary.UnsafeReason})."); + CurrentSession.Value = null; + return null; + } catch (Exception ex) { // Best-effort: a metrics sink that cannot open its file must not block the command. @@ -133,6 +159,7 @@ internal sealed class Session : IDisposable private readonly Action? _warningSink; private readonly Func _retryDelay; private readonly TimeSpan _disposeWriterTimeout; + private readonly RepositoryOutputPathGuard? _boundary; private long _bytesWritten; private long _pendingEventCount; private long _queueDepth; @@ -162,7 +189,8 @@ public Session( Action? warningSink, int? queueCapacity = null, Func? retryDelay = null, - TimeSpan? disposeWriterTimeout = null) + TimeSpan? disposeWriterTimeout = null, + RepositoryOutputPathGuard? boundary = null) { Path = path; _maxBytes = maxBytes; @@ -170,6 +198,7 @@ public Session( _warningSink = warningSink; _retryDelay = retryDelay ?? ((delay, cancellationToken) => Task.Delay(delay, cancellationToken)); _disposeWriterTimeout = disposeWriterTimeout ?? DisposeWriterTimeout; + _boundary = boundary; if (_disposeWriterTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(disposeWriterTimeout)); _queueCapacity = ResolveQueueCapacity(queueCapacity); @@ -413,7 +442,7 @@ private void WriteBatch(IReadOnlyList sourceBatch) try { - using (var stream = PrivateLogFile.OpenAppend(Path, FileShare.ReadWrite)) + using (var stream = PrivateLogFile.OpenAppend(Path, FileShare.ReadWrite, _boundary)) { for (var i = start; i < index; i++) stream.Write(batch[i].Encoded, 0, batch[i].Encoded.Length); @@ -450,7 +479,8 @@ private bool TryRotate(IReadOnlyList batch, int dropStart) if (PrivateLogFile.TryRotateSlots( Path, RotationKeep, - onFailure: ex => capturedFailure = ex)) + onFailure: ex => capturedFailure = ex, + boundary: _boundary)) { Volatile.Write(ref _bytesWritten, 0); return true; diff --git a/src/CodeIndex/Cli/PrivateLogFile.cs b/src/CodeIndex/Cli/PrivateLogFile.cs index 2b3770a0d..0679d3736 100644 --- a/src/CodeIndex/Cli/PrivateLogFile.cs +++ b/src/CodeIndex/Cli/PrivateLogFile.cs @@ -9,37 +9,130 @@ internal static class PrivateLogFile internal const int MaxExistingFilesToHarden = 128; private const int MaxDiagnosticTargetChars = 160; - internal static FileStream OpenAppend(string path, FileShare share = FileShare.ReadWrite) + internal static Stream OpenAppend( + string path, + FileShare share = FileShare.ReadWrite, + RepositoryOutputPathGuard? boundary = null) { + boundary?.PrepareMutation("open_append", path); RejectUnsafeTarget(path); - if (OperatingSystem.IsWindows()) - return new FileStream(path, FileMode.Append, FileAccess.Write, share); + Stream stream; + if (boundary is not null) + { + stream = OperatingSystem.IsWindows() + ? boundary.OpenAppendWindows(path, share) + : boundary.OpenAppendUnix(path); + } + else if (OperatingSystem.IsWindows()) + { + stream = new FileStream(path, FileMode.Append, FileAccess.Write, share); + } + else + { + stream = new FileStream(path, new FileStreamOptions + { + Mode = FileMode.Append, + Access = FileAccess.Write, + Share = share, + UnixCreateMode = PrivateFileMode, + }); + } - return new FileStream(path, new FileStreamOptions + try + { + boundary?.CompleteMutation(path); + return stream; + } + catch { - Mode = FileMode.Append, - Access = FileAccess.Write, - Share = share, - UnixCreateMode = PrivateFileMode, - }); + stream.Dispose(); + throw; + } } - internal static StreamWriter OpenAppendText(string path) - => new(OpenAppend(path), new UTF8Encoding(false)) + internal static StreamWriter OpenAppendText(string path, RepositoryOutputPathGuard? boundary = null) + => new(OpenAppend(path, boundary: boundary), new UTF8Encoding(false)) { AutoFlush = true, }; - internal static void TrySetPrivatePermissions(string path, Action? diagnosticSink = null) + internal static void WritePrivateTextReplacing( + string path, + string contents, + RepositoryOutputPathGuard? boundary = null) + { + if (boundary is null) + { + DataDirectorySecurity.WritePrivateText(path, contents); + return; + } + + boundary.PrepareMutation("write_private_text", path); + var stagedPath = path + $".{Guid.NewGuid():N}.tmp"; + try + { + boundary.PrepareMutation("write_private_text_stage", stagedPath); + RejectUnsafeTarget(stagedPath); + using (var stream = OperatingSystem.IsWindows() + ? boundary.OpenReplacingWindows(stagedPath, createNew: true) + : boundary.OpenReplacingUnix(stagedPath)) + { + var encoded = Encoding.UTF8.GetBytes(contents); + stream.Write(encoded, 0, encoded.Length); + stream.Flush(flushToDisk: true); + } + boundary.CompleteMutation(stagedPath); + TrySetPrivatePermissions(stagedPath, boundary: boundary); + + boundary.PrepareMutation("write_private_text_publish_source", stagedPath); + boundary.PrepareMutation("write_private_text_publish_destination", path); + if (OperatingSystem.IsWindows()) + boundary.MoveReplacingWindows(stagedPath, path); + else + boundary.MoveReplacingUnix(stagedPath, path); + boundary.CompleteMutation(path); + } + finally + { + TryDeleteStagedFile(stagedPath, boundary); + } + } + + private static void TryDeleteStagedFile(string path, RepositoryOutputPathGuard boundary) + { + try + { + boundary.PrepareMutation("write_private_text_cleanup", path); + if (OperatingSystem.IsWindows()) + boundary.DeleteFileWindows(path); + else + boundary.DeleteFileUnix(path); + boundary.CompleteMutation(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + // Best-effort cleanup only / cleanup はベストエフォートのみ + } + } + + internal static void TrySetPrivatePermissions( + string path, + Action? diagnosticSink = null, + RepositoryOutputPathGuard? boundary = null) { if (OperatingSystem.IsWindows()) return; try { + boundary?.PrepareMutation("set_private_permissions", path); RejectUnsafeTarget(path); - File.SetUnixFileMode(path, PrivateFileMode); + if (boundary is null) + File.SetUnixFileMode(path, PrivateFileMode); + else + boundary.SetPrivateFileModeUnix(path); + boundary?.CompleteMutation(path); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) { @@ -47,7 +140,11 @@ internal static void TrySetPrivatePermissions(string path, Action? diagnosticSink = null) + internal static void HardenExisting( + string directory, + string pattern, + Action? diagnosticSink = null, + RepositoryOutputPathGuard? boundary = null) { if (OperatingSystem.IsWindows()) return; @@ -65,7 +162,7 @@ internal static void HardenExisting(string directory, string pattern, Action? diagnosticSink = null, - Action? deleteOverride = null) + Action? deleteOverride = null, + RepositoryOutputPathGuard? boundary = null) { try { @@ -131,10 +229,22 @@ internal static void PruneOldFiles( { if (ShouldPruneFile(file, retainedPaths, retainedFiles, retainedFileCount)) { - AtomicFileWriter.TryDeleteFile( - file.FullName, - ex => ReportDiagnostic(diagnosticSink, "prune_old_file_delete", file.FullName, ex), - deleteOverride); + boundary?.PrepareMutation("prune_old_file", file.FullName); + if (boundary is not null) + { + if (OperatingSystem.IsWindows()) + boundary.DeleteFileWindows(file.FullName); + else + boundary.DeleteFileUnix(file.FullName); + } + else + { + AtomicFileWriter.TryDeleteFile( + file.FullName, + ex => ReportDiagnostic(diagnosticSink, "prune_old_file_delete", file.FullName, ex), + deleteOverride); + } + boundary?.CompleteMutation(file.FullName); } } } @@ -201,11 +311,23 @@ internal static bool TryRotateSlots( int retainedFileCount, Action? afterMove = null, Action? onFailure = null, - Action? onCleanupFailure = null) + Action? onCleanupFailure = null, + RepositoryOutputPathGuard? boundary = null) { try { - AtomicFileWriter.TryDeleteFile(SlotPath(path, retainedFileCount - 1), onCleanupFailure); + var lastSlot = SlotPath(path, retainedFileCount - 1); + boundary?.PrepareMutation("rotate_delete", lastSlot); + if (boundary is not null) + { + if (OperatingSystem.IsWindows()) + boundary.DeleteFileWindows(lastSlot); + else + boundary.DeleteFileUnix(lastSlot); + } + else + AtomicFileWriter.TryDeleteFile(lastSlot, onCleanupFailure); + boundary?.CompleteMutation(lastSlot); for (var slot = retainedFileCount - 2; slot >= 1; slot--) { @@ -213,14 +335,38 @@ internal static bool TryRotateSlots( var next = SlotPath(path, slot + 1); if (!File.Exists(LongPath.EnsureWindowsPrefix(current))) continue; - AtomicFileWriter.MoveReplacing(current, next); + boundary?.PrepareMutation("rotate_source", current); + boundary?.PrepareMutation("rotate_destination", next); + if (boundary is not null) + { + if (OperatingSystem.IsWindows()) + boundary.MoveReplacingWindows(current, next); + else + boundary.MoveReplacingUnix(current, next); + } + else + AtomicFileWriter.MoveReplacing(current, next); + boundary?.CompleteMutation(current); + boundary?.CompleteMutation(next); afterMove?.Invoke(next); } if (File.Exists(LongPath.EnsureWindowsPrefix(path))) { var first = SlotPath(path, 1); - AtomicFileWriter.MoveReplacing(path, first); + boundary?.PrepareMutation("rotate_source", path); + boundary?.PrepareMutation("rotate_destination", first); + if (boundary is not null) + { + if (OperatingSystem.IsWindows()) + boundary.MoveReplacingWindows(path, first); + else + boundary.MoveReplacingUnix(path, first); + } + else + AtomicFileWriter.MoveReplacing(path, first); + boundary?.CompleteMutation(path); + boundary?.CompleteMutation(first); afterMove?.Invoke(first); } diff --git a/src/CodeIndex/Cli/ProgramRunner.Metrics.cs b/src/CodeIndex/Cli/ProgramRunner.Metrics.cs index a0a3e33fb..6febd994a 100644 --- a/src/CodeIndex/Cli/ProgramRunner.Metrics.cs +++ b/src/CodeIndex/Cli/ProgramRunner.Metrics.cs @@ -154,7 +154,7 @@ internal static bool TryConsumeQueryTraceFlag(string? commandName, ref string[] return true; } - private static void EmitQueryTrace(string mode, string commandName, string[] subArgs, DateTimeOffset startTimestamp, Stopwatch stopwatch, int exitCode, int? resultCount) + internal static void EmitQueryTrace(string mode, string commandName, string[] subArgs, DateTimeOffset startTimestamp, Stopwatch stopwatch, int exitCode, int? resultCount) { if (mode == "none") return; @@ -169,18 +169,27 @@ private static void EmitQueryTrace(string mode, string commandName, string[] sub return; } - var directory = GlobalToolLog.ResolveLogDirectoryForStatus(); - Directory.CreateDirectory(directory); - PrivateLogFile.HardenExisting(directory, "query-trace-*.jsonl"); + var selection = GlobalToolLog.ResolveLogDirectorySelectionForRepositoryWrite(); + var directory = selection.Path; + var boundary = selection.Boundary; + if (boundary is null) + Directory.CreateDirectory(directory); + else + boundary.CreateSensitiveDestinationDirectory(); + PrivateLogFile.HardenExisting(directory, "query-trace-*.jsonl", boundary: boundary); var path = ResolveQueryTracePath(directory); var encoded = Encoding.UTF8.GetBytes(payload + Environment.NewLine); - using (var stream = PrivateLogFile.OpenAppend(path, FileShare.ReadWrite)) + using (var stream = PrivateLogFile.OpenAppend(path, FileShare.ReadWrite, boundary)) { stream.Write(encoded, 0, encoded.Length); stream.Flush(); } - PrivateLogFile.TrySetPrivatePermissions(path); - PrivateLogFile.PruneOldFiles(directory, "query-trace-*.jsonl", RetainedQueryTraceFileCount); + PrivateLogFile.TrySetPrivatePermissions(path, boundary: boundary); + PrivateLogFile.PruneOldFiles( + directory, + "query-trace-*.jsonl", + RetainedQueryTraceFileCount, + boundary: boundary); } catch { diff --git a/src/CodeIndex/Cli/RepositoryOutputPathBoundary.cs b/src/CodeIndex/Cli/RepositoryOutputPathBoundary.cs new file mode 100644 index 000000000..527ca9e8d --- /dev/null +++ b/src/CodeIndex/Cli/RepositoryOutputPathBoundary.cs @@ -0,0 +1,1529 @@ +using System.Runtime.InteropServices; +using System.Text; +using CodeIndex.Indexer; +using Microsoft.Win32.SafeHandles; + +namespace CodeIndex.Cli; + +/// +/// Keeps writable destinations supplied by repository configuration below the config +/// workspace without following repository-controlled filesystem aliases. +/// repository config が指定する writable destination を、repository-controlled な +/// filesystem alias を辿らず config workspace 配下に保持する。 +/// +internal static class RepositoryOutputPathBoundary +{ + internal const string UnsafeReason = "unsafe_output_path"; + private const string UnsafeMessage = + "symbolic links, junctions, bind or cross-device mount points, reparse points, devices, and dangling links are not allowed below the config workspace root"; + private static readonly AsyncLocal?> BeforeMutation = new(); + private static readonly AsyncLocal?> ContainsPath = new(); + private static readonly AsyncLocal?> UnixMountId = new(); + private static readonly AsyncLocal?> UnixHandleMountId = new(); + private static readonly AsyncLocal?> UnixDirectoryFsync = new(); + private static readonly AsyncLocal?> BeforeWindowsNativeMutation = new(); + + internal static Action? BeforeMutationForTesting + { + get => BeforeMutation.Value; + set => BeforeMutation.Value = value; + } + + internal static Func? ContainsPathForTesting + { + get => ContainsPath.Value; + set => ContainsPath.Value = value; + } + + internal static Func? UnixMountIdForTesting + { + get => UnixMountId.Value; + set => UnixMountId.Value = value; + } + + internal static Func? UnixHandleMountIdForTesting + { + get => UnixHandleMountId.Value; + set => UnixHandleMountId.Value = value; + } + + internal static Func? UnixDirectoryFsyncForTesting + { + get => UnixDirectoryFsync.Value; + set => UnixDirectoryFsync.Value = value; + } + + internal static Action? BeforeWindowsNativeMutationForTesting + { + get => BeforeWindowsNativeMutation.Value; + set => BeforeWindowsNativeMutation.Value = value; + } + + internal static bool TryResolveConfiguredPath( + string rawPath, + string workspaceRoot, + bool destinationIsDirectory, + out string resolvedPath, + out string failureReason) + { + resolvedPath = string.Empty; + failureReason = string.Empty; + try + { + var normalizedRoot = PathCasing.NormalizeBoundaryPath(workspaceRoot); + var fullPath = Path.IsPathRooted(rawPath) + ? Path.GetFullPath(rawPath) + : Path.GetFullPath(Path.Combine(normalizedRoot, rawPath)); + var normalizedPath = PathCasing.NormalizeBoundaryPath(fullPath); + if (!IsPathEqualOrParent(normalizedRoot, normalizedPath)) + { + failureReason = "outside_workspace"; + return false; + } + + ValidatePathComponents(normalizedRoot, normalizedPath, destinationIsDirectory); + resolvedPath = fullPath; + return true; + } + catch (RepositoryOutputPathBoundaryException) + { + failureReason = UnsafeReason; + return false; + } + catch (Exception ex) when (IsPathException(ex)) + { + failureReason = "invalid_path"; + return false; + } + } + + internal static RepositoryOutputPathGuard? CreateGuardForConfigSource( + string environmentVariable, + string fieldName, + string destinationPath, + bool destinationIsDirectory) + { + var sourcePath = CdidxConfigSourceResolver.GetSource(environmentVariable); + if (string.IsNullOrWhiteSpace(sourcePath)) + return null; + + var workspaceRoot = CdidxConfigFile.ResolveConfigWorkspaceRoot(sourcePath); + if (!TryResolveConfiguredPath( + destinationPath, + workspaceRoot, + destinationIsDirectory, + out var resolvedPath, + out var failureReason)) + { + throw CreateException(fieldName, failureReason); + } + + return new RepositoryOutputPathGuard( + fieldName, + PathCasing.NormalizeBoundaryPath(workspaceRoot), + PathCasing.NormalizeBoundaryPath(resolvedPath), + destinationIsDirectory); + } + + internal static RepositoryOutputPathBoundaryException CreateException(string fieldName, string reason) + => new($"repository-configured `{fieldName}` rejected ({reason}): {UnsafeMessage}."); + + internal static string ResolveCanonicalWorkspaceRoot(string workspaceRoot) + { + if (OperatingSystem.IsWindows()) + return RepositoryOutputPathGuard.ResolveCanonicalWindowsDirectory(workspaceRoot); + + IntPtr pointer = IntPtr.Zero; + try + { + pointer = UnixRealPath(workspaceRoot, IntPtr.Zero); + var resolved = pointer == IntPtr.Zero ? null : Marshal.PtrToStringUTF8(pointer); + return string.IsNullOrWhiteSpace(resolved) + ? PathCasing.NormalizeBoundaryPath(workspaceRoot) + : PathCasing.NormalizeBoundaryPath(resolved); + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return PathCasing.NormalizeBoundaryPath(workspaceRoot); + } + finally + { + if (pointer != IntPtr.Zero) + UnixFree(pointer); + } + } + + internal static void ValidatePathComponents( + string workspaceRoot, + string path, + bool destinationIsDirectory) + { + var normalizedRoot = PathCasing.NormalizeBoundaryPath(workspaceRoot); + var normalizedPath = PathCasing.NormalizeBoundaryPath(path); + if (!IsPathEqualOrParent(normalizedRoot, normalizedPath)) + throw CreateException("output path", "outside_workspace"); + + var rootAttributesStatus = FileSystemBoundary.TryGetAttributes(normalizedRoot, out var rootAttributes); + if (rootAttributesStatus != FileSystemBoundaryProbeStatus.Found + || (rootAttributes & FileAttributes.Directory) == 0 + || FileSystemBoundary.IsDevice(rootAttributes)) + { + throw CreateException("output path", UnsafeReason); + } + + long? rootDevice = null; + if (!OperatingSystem.IsWindows()) + { + if (!TryGetUnixStatus(normalizedRoot, out var rootStatus) + || (rootStatus.Mode & UnixFileTypeMask) != UnixDirectoryType) + { + throw CreateException("output path", UnsafeReason); + } + rootDevice = rootStatus.Device; + } + ulong? rootMountId = null; + if (RequiresUnixMountIdentity) + { + var canonicalRoot = ResolveCanonicalWorkspaceRoot(normalizedRoot); + if (!TryGetUnixMountId(canonicalRoot, out var resolvedRootMountId)) + throw CreateException("output path", UnsafeReason); + rootMountId = resolvedRootMountId; + } + var relative = Path.GetRelativePath(normalizedRoot, normalizedPath); + if (relative == ".") + return; + + var components = relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + var current = normalizedRoot; + for (var index = 0; index < components.Length; index++) + { + var component = components[index]; + if (component is "." or "..") + throw CreateException("output path", UnsafeReason); + + current = Path.Combine(current, component); + if (IsLinkOrReparseEntry(current)) + throw CreateException("output path", UnsafeReason); + + var status = FileSystemBoundary.TryGetAttributes(current, out var attributes); + if (status == FileSystemBoundaryProbeStatus.Missing) + continue; + if (status != FileSystemBoundaryProbeStatus.Found + || FileSystemBoundary.IsSymlinkOrReparsePoint(attributes) + || FileSystemBoundary.IsDevice(attributes)) + { + throw CreateException("output path", UnsafeReason); + } + + var isFinal = index == components.Length - 1; + var isDirectory = (attributes & FileAttributes.Directory) != 0; + if ((!isFinal && !isDirectory) || (isFinal && destinationIsDirectory && !isDirectory)) + throw CreateException("output path", UnsafeReason); + if (isFinal && !destinationIsDirectory && isDirectory) + throw CreateException("output path", UnsafeReason); + + if (rootDevice.HasValue + && (!TryGetUnixStatus(current, out var currentStatus) + || currentStatus.Device != rootDevice.Value + || (currentStatus.Mode & UnixFileTypeMask) + != (isDirectory ? UnixDirectoryType : UnixRegularFileType))) + { + throw CreateException("output path", UnsafeReason); + } + if (rootMountId.HasValue + && (!TryGetUnixMountId(current, out var currentMountId) + || currentMountId != rootMountId.Value)) + { + throw CreateException("output path", UnsafeReason); + } + } + } + + private static bool IsLinkOrReparseEntry(string path) + { + try + { + var directory = new DirectoryInfo(path); + directory.Refresh(); + if (!string.IsNullOrEmpty(directory.LinkTarget)) + return true; + } + catch (Exception ex) when (IsPathException(ex)) + { + if (ex is UnauthorizedAccessException) + throw; + } + + try + { + var file = new FileInfo(path); + file.Refresh(); + return !string.IsNullOrEmpty(file.LinkTarget); + } + catch (Exception ex) when (IsPathException(ex)) + { + if (ex is UnauthorizedAccessException) + throw; + return false; + } + } + + private static bool TryGetUnixStatus(string path, out UnixFileStatus status) + { + status = default; + if (OperatingSystem.IsWindows()) + return false; + + try + { + return UnixStat(LongPath.EnsureWindowsPrefix(path), out status) == 0; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return false; + } + } + + internal static bool RequiresUnixMountIdentity => + OperatingSystem.IsLinux() + || UnixMountIdForTesting is not null + || UnixHandleMountIdForTesting is not null; + + internal static bool TryGetUnixMountId(string path, out ulong mountId) + { + mountId = 0; + if (UnixMountIdForTesting is { } provider) + { + var value = provider(path); + if (value.HasValue) + { + mountId = value.Value; + return true; + } + + return OperatingSystem.IsLinux() + && TryGetLinuxMountIdFromPathDescriptor(path, out mountId); + } + + if (!OperatingSystem.IsLinux()) + return false; + + try + { + if (UnixStatX( + UnixCurrentWorkingDirectory, + path, + UnixAtSymlinkNoFollow, + UnixStatXMountId, + out var status) == 0 + && (status.Mask & UnixStatXMountId) != 0) + { + mountId = status.MountId; + return true; + } + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + } + + return TryGetLinuxMountIdFromPathDescriptor(path, out mountId); + } + + internal static bool TryGetUnixMountId(SafeFileHandle handle, out ulong mountId) + { + mountId = 0; + var descriptor = handle.DangerousGetHandle().ToInt32(); + if (UnixHandleMountIdForTesting is { } provider) + { + var value = provider(descriptor); + if (value.HasValue) + { + mountId = value.Value; + return true; + } + + return OperatingSystem.IsLinux() + && TryGetLinuxMountIdFromFdInfo(descriptor, out mountId); + } + + if (!OperatingSystem.IsLinux()) + return false; + + try + { + if (UnixStatX( + descriptor, + string.Empty, + UnixAtEmptyPath | UnixAtSymlinkNoFollow, + UnixStatXMountId, + out var status) == 0 + && (status.Mask & UnixStatXMountId) != 0) + { + mountId = status.MountId; + return true; + } + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + } + + return TryGetLinuxMountIdFromFdInfo(descriptor, out mountId); + } + + internal static bool TryParseLinuxFdInfoMountId(string fdInfo, out ulong mountId) + { + mountId = 0; + using var reader = new StringReader(fdInfo); + while (reader.ReadLine() is { } line) + { + const string Prefix = "mnt_id:"; + if (!line.StartsWith(Prefix, StringComparison.Ordinal)) + continue; + + return ulong.TryParse(line.AsSpan(Prefix.Length).Trim(), out mountId); + } + + return false; + } + + private static bool TryGetLinuxMountIdFromPathDescriptor(string path, out ulong mountId) + { + mountId = 0; + var descriptor = UnixOpenMountProbe( + path, + UnixPathOnly | UnixOpenCloseOnExec | UnixOpenNoFollow); + if (descriptor < 0) + return false; + + using var handle = new SafeFileHandle(new IntPtr(descriptor), ownsHandle: true); + return TryGetLinuxMountIdFromFdInfo(descriptor, out mountId); + } + + private static bool TryGetLinuxMountIdFromFdInfo(int descriptor, out ulong mountId) + { + mountId = 0; + try + { + var fdInfo = File.ReadAllText($"/proc/self/fdinfo/{descriptor}"); + return TryParseLinuxFdInfoMountId(fdInfo, out mountId); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + internal static int FsyncUnixDirectory(SafeFileHandle handle) + { + var descriptor = handle.DangerousGetHandle().ToInt32(); + if (UnixDirectoryFsyncForTesting is { } fsync) + return fsync(descriptor); + + int result; + do + { + result = UnixFsync(descriptor); + } + while (result != 0 && Marshal.GetLastWin32Error() == 4); + return result; + } + + private static bool IsPathException(Exception ex) + => ex is ArgumentException + or IOException + or NotSupportedException + or PathTooLongException + or UnauthorizedAccessException; + + internal static bool IsPathEqualOrParent(string parent, string child) + => ContainsPathForTesting?.Invoke(parent, child) + ?? PathCasing.IsPathEqualOrParentByDirectoryNamespace(parent, child); + + [DllImport("libSystem.Native", EntryPoint = "SystemNative_Stat", CharSet = CharSet.Ansi)] + private static extern int UnixStat(string path, out UnixFileStatus status); + + [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("libc", EntryPoint = "statx", SetLastError = true)] + private static extern int UnixStatX( + int directory, + string path, + int flags, + uint mask, + out UnixStatXStatus status); + + [DllImport("libc", EntryPoint = "fsync", SetLastError = true)] + private static extern int UnixFsync(int descriptor); + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int UnixOpenMountProbe(string path, int flags); + + private const int UnixCurrentWorkingDirectory = -100; + private const int UnixAtSymlinkNoFollow = 0x100; + private const int UnixAtEmptyPath = 0x1000; + private const int UnixPathOnly = 0x200000; + private const int UnixOpenCloseOnExec = 0x80000; + private const int UnixOpenNoFollow = 0x20000; + private const uint UnixStatXMountId = 0x1000; + private const int UnixFileTypeMask = 0xF000; + private const int UnixDirectoryType = 0x4000; + private const int UnixRegularFileType = 0x8000; + + [StructLayout(LayoutKind.Explicit, Size = 256)] + private struct UnixStatXStatus + { + [FieldOffset(0)] + internal uint Mask; + + [FieldOffset(144)] + internal ulong MountId; + } + + [StructLayout(LayoutKind.Sequential)] + private struct UnixFileStatus + { + internal uint Flags; + internal int Mode; + internal uint Uid; + internal uint Gid; + internal long Size; + internal long ATime; + internal long ATimeNsec; + internal long MTime; + internal long MTimeNsec; + internal long CTime; + internal long CTimeNsec; + internal long BirthTime; + internal long BirthTimeNsec; + internal long Device; + internal long RDevice; + internal long Inode; + internal uint UserFlags; + } +} + +internal sealed class RepositoryOutputPathGuard +{ + internal RepositoryOutputPathGuard( + string fieldName, + string workspaceRoot, + string destinationPath, + bool destinationIsDirectory) + { + FieldName = fieldName; + WorkspaceRoot = workspaceRoot; + CanonicalWorkspaceRoot = RepositoryOutputPathBoundary.ResolveCanonicalWorkspaceRoot(workspaceRoot); + DestinationPath = destinationPath; + DestinationIsDirectory = destinationIsDirectory; + } + + internal string FieldName { get; } + internal string WorkspaceRoot { get; } + internal string CanonicalWorkspaceRoot { get; } + internal string DestinationPath { get; } + internal bool DestinationIsDirectory { get; } + + internal static string ResolveCanonicalWindowsDirectory(string path) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException(); + + using var handle = WindowsCreateFile( + LongPath.EnsureWindowsPrefix(path), + WindowsFileListDirectory | WindowsFileReadAttributes | WindowsSynchronize, + WindowsShareRead | WindowsShareWrite | WindowsShareDelete, + IntPtr.Zero, + WindowsOpenExisting, + WindowsFileFlagBackupSemantics, + IntPtr.Zero); + if (handle.IsInvalid) + throw CreateNativeIOException(); + + return PathCasing.NormalizeBoundaryPath(GetWindowsFinalPath(handle)); + } + + internal void CreateSensitiveDestinationDirectory() + { + var directory = DestinationIsDirectory + ? DestinationPath + : Path.GetDirectoryName(DestinationPath); + if (string.IsNullOrWhiteSpace(directory)) + return; + + CreateSensitiveDirectories(directory); + } + + internal void PrepareMutation(string operation, string path, bool expectsDirectory = false) + { + ValidateRelatedPath(path, expectsDirectory); + RepositoryOutputPathBoundary.BeforeMutationForTesting?.Invoke(operation, path); + ValidateRelatedPath(path, expectsDirectory); + } + + internal void CompleteMutation(string path, bool expectsDirectory = false) + => ValidateRelatedPath(path, expectsDirectory); + + internal Stream OpenAppendUnix(string path) + { + if (OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException(); + + var handle = OpenUnixFile(path, create: true, append: true); + try + { + return new UnixAppendStream(handle); + } + catch + { + handle.Dispose(); + throw; + } + } + + internal Stream OpenAppendWindows(string path, FileShare share) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException(); + + var handle = OpenWindowsPath( + path, + WindowsFileAppendData | WindowsFileReadAttributes | WindowsFileWriteAttributes | WindowsSynchronize, + (uint)share, + WindowsFileOpenIf, + WindowsFileNormal, + WindowsFileNonDirectory | WindowsFileSynchronousIoNonAlert); + try + { + return new FileStream(handle, FileAccess.Write, bufferSize: 4096, isAsync: false); + } + catch + { + handle.Dispose(); + throw; + } + } + + internal FileStream OpenReplacingUnix(string path) + { + if (OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException(); + + var handle = OpenUnixFile(path, create: true, append: false, truncate: true); + try + { + return new FileStream(handle, FileAccess.Write); + } + catch + { + handle.Dispose(); + throw; + } + } + + internal FileStream OpenReplacingWindows(string path, bool createNew) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException(); + + var handle = OpenWindowsPath( + path, + WindowsFileWriteData | WindowsFileReadAttributes | WindowsFileWriteAttributes | WindowsSynchronize, + shareAccess: 0, + createNew ? WindowsFileCreate : WindowsFileOverwriteIf, + WindowsFileNormal, + WindowsFileNonDirectory | WindowsFileSynchronousIoNonAlert); + try + { + return new FileStream(handle, FileAccess.Write, bufferSize: 4096, isAsync: false); + } + catch + { + handle.Dispose(); + throw; + } + } + + internal void SetPrivateFileModeUnix(string path) + { + if (OperatingSystem.IsWindows()) + return; + + using var handle = OpenUnixFile(path, create: false, append: false); + if (UnixFChmod(handle.DangerousGetHandle().ToInt32(), (uint)PrivateLogFile.PrivateFileMode) != 0) + throw CreateNativeIOException(); + } + + internal bool DeleteFileUnix(string path) + { + if (OperatingSystem.IsWindows()) + return false; + + using var parent = OpenUnixParentDirectory(path); + var result = UnixUnlinkAt(parent.DangerousGetHandle().ToInt32(), Path.GetFileName(path), flags: 0); + if (result == 0) + return true; + if (Marshal.GetLastWin32Error() == 2) + return false; + throw CreateNativeIOException(); + } + + internal bool DeleteFileWindows(string path) + { + if (!OperatingSystem.IsWindows()) + return false; + + using var root = OpenWindowsRootDirectory(); + var relativePath = GetWindowsRelativePath(path); + using var objectName = new WindowsObjectName(root, relativePath); + RepositoryOutputPathBoundary.BeforeWindowsNativeMutationForTesting?.Invoke("delete", path); + var status = WindowsNtDeleteFile(ref objectName.Attributes); + if (status >= 0) + return true; + if (status is WindowsStatusObjectNameNotFound or WindowsStatusObjectPathNotFound) + return false; + throw CreateNativeIOException(); + } + + internal void MoveReplacingUnix(string source, string destination) + { + if (OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException(); + + using var sourceParent = OpenUnixParentDirectory(source); + using var destinationParent = OpenUnixParentDirectory(destination); + if (UnixRenameAt( + sourceParent.DangerousGetHandle().ToInt32(), + Path.GetFileName(source), + destinationParent.DangerousGetHandle().ToInt32(), + Path.GetFileName(destination)) != 0) + { + throw CreateNativeIOException(); + } + if (RepositoryOutputPathBoundary.FsyncUnixDirectory(destinationParent) != 0) + { + throw new IOException( + "Guarded replace completed, but the target file was already replaced " + + "and its parent directory could not be flushed."); + } + } + + internal void MoveReplacingWindows(string source, string destination) + { + if (!OperatingSystem.IsWindows()) + throw new PlatformNotSupportedException(); + + using var sourceHandle = OpenWindowsPath( + source, + WindowsDelete | WindowsFileReadAttributes | WindowsSynchronize, + WindowsShareRead | WindowsShareWrite | WindowsShareDelete, + WindowsFileOpen, + WindowsFileNormal, + WindowsFileNonDirectory | WindowsFileSynchronousIoNonAlert); + var destinationParentPath = Path.GetDirectoryName(Path.GetFullPath(destination)) + ?? throw RepositoryOutputPathBoundary.CreateException(FieldName, "invalid_path"); + using var destinationParent = PathCasing.PathsEqualByDirectoryNamespace( + WorkspaceRoot, + destinationParentPath) + ? OpenWindowsRootDirectory() + : OpenWindowsPath( + destinationParentPath, + WindowsFileListDirectory | WindowsFileReadAttributes | WindowsSynchronize, + WindowsShareRead | WindowsShareWrite | WindowsShareDelete, + WindowsFileOpen, + WindowsFileDirectory, + WindowsFileDirectoryOption | WindowsFileSynchronousIoNonAlert); + + var destinationName = Path.GetFileName(destination); + var encodedName = Encoding.Unicode.GetBytes(destinationName); + var rootOffset = IntPtr.Size; + var lengthOffset = rootOffset + IntPtr.Size; + var nameOffset = lengthOffset + sizeof(uint); + var buffer = Marshal.AllocHGlobal(nameOffset + encodedName.Length); + try + { + for (var index = 0; index < nameOffset; index++) + Marshal.WriteByte(buffer, index, 0); + Marshal.WriteByte(buffer, 0, 1); + Marshal.WriteIntPtr(buffer, rootOffset, destinationParent.DangerousGetHandle()); + Marshal.WriteInt32(buffer, lengthOffset, encodedName.Length); + Marshal.Copy(encodedName, 0, IntPtr.Add(buffer, nameOffset), encodedName.Length); + RepositoryOutputPathBoundary.BeforeWindowsNativeMutationForTesting?.Invoke("rename", source); + using var verificationRoot = OpenWindowsRootDirectory(); + EnsureWindowsHandleUnderRoot(verificationRoot, sourceHandle); + EnsureWindowsHandleUnderRoot(verificationRoot, destinationParent); + var status = WindowsNtSetInformationFile( + sourceHandle, + out _, + buffer, + (uint)(nameOffset + encodedName.Length), + WindowsFileRenameInformation); + if (status < 0) + throw CreateNativeIOException(); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + + internal void ValidateRelatedPath(string path, bool expectsDirectory = false) + { + var fullPath = Path.GetFullPath(path); + if (!Allows(fullPath, expectsDirectory)) + throw RepositoryOutputPathBoundary.CreateException(FieldName, "outside_workspace"); + + expectsDirectory |= string.Equals( + PathCasing.NormalizeBoundaryPath(fullPath), + PathCasing.NormalizeBoundaryPath(DestinationPath), + PathCasing.ComparisonFor(DestinationPath)) + && DestinationIsDirectory; + RepositoryOutputPathBoundary.ValidatePathComponents(WorkspaceRoot, fullPath, expectsDirectory); + } + + private void CreateSensitiveDirectories(string directory) + { + if (OperatingSystem.IsWindows()) + { + CreateSensitiveDirectoriesWindows(directory); + return; + } + + CreateSensitiveDirectoriesUnix(directory); + } + + private void CreateSensitiveDirectoriesWindows(string directory) + { + var normalizedDirectory = PathCasing.NormalizeBoundaryPath(directory); + var components = GetRelativeComponents(normalizedDirectory); + if (components.Length == 0) + return; + + for (var index = 0; index < components.Length; index++) + { + var currentPath = Path.Combine(WorkspaceRoot, Path.Combine(components[..(index + 1)])); + PrepareMutation("create_directory", currentPath, expectsDirectory: true); + using var handle = OpenWindowsPath( + currentPath, + WindowsFileListDirectory + | WindowsFileAddSubdirectory + | WindowsFileReadAttributes + | WindowsFileWriteAttributes + | WindowsSynchronize, + WindowsShareRead | WindowsShareWrite | WindowsShareDelete, + WindowsFileOpenIf, + WindowsFileDirectory, + WindowsFileDirectoryOption | WindowsFileSynchronousIoNonAlert); + CompleteMutation(currentPath, expectsDirectory: true); + } + } + + private void CreateSensitiveDirectoriesUnix(string directory) + { + var normalizedDirectory = PathCasing.NormalizeBoundaryPath(directory); + var components = GetRelativeComponents(normalizedDirectory); + using var root = OpenUnixRootDirectory(out var rootIdentity); + SafeFileHandle current = root; + try + { + for (var index = 0; index < components.Length; index++) + { + var component = components[index]; + var currentPath = Path.Combine(WorkspaceRoot, Path.Combine(components[..(index + 1)])); + SafeFileHandle? next = null; + try + { + next = TryOpenUnixDirectoryAt(current, component); + var created = false; + if (next is null) + { + PrepareMutation("create_directory", currentPath, expectsDirectory: true); + var parentPath = index == 0 + ? WorkspaceRoot + : Path.Combine(WorkspaceRoot, Path.Combine(components[..index])); + using var reboundParent = OpenUnixDirectory(parentPath, out var reboundIdentity); + if (reboundIdentity != rootIdentity) + throw CreateNativeIOException(); + if (UnixMkdirAt( + reboundParent.DangerousGetHandle().ToInt32(), + component, + (uint)DataDirectorySecurity.PrivateDirectoryMode) != 0 + && Marshal.GetLastWin32Error() != 17) + { + throw CreateNativeIOException(); + } + next = TryOpenUnixDirectoryAt(reboundParent, component) ?? throw CreateNativeIOException(); + CompleteMutation(currentPath, expectsDirectory: true); + created = true; + } + + EnsureUnixObject(next, rootIdentity, expectedType: UnixDirectoryType); + if (created || index == components.Length - 1) + { + PrepareMutation("set_directory_permissions", currentPath, expectsDirectory: true); + next.Dispose(); + next = OpenUnixDirectory(currentPath, out var reboundIdentity); + if (reboundIdentity != rootIdentity) + throw CreateNativeIOException(); + if (UnixFChmod( + next.DangerousGetHandle().ToInt32(), + (uint)DataDirectorySecurity.PrivateDirectoryMode) != 0) + { + throw CreateNativeIOException(); + } + CompleteMutation(currentPath, expectsDirectory: true); + } + } + catch + { + next?.Dispose(); + throw; + } + + if (!ReferenceEquals(current, root)) + current.Dispose(); + current = next; + } + } + finally + { + if (!ReferenceEquals(current, root)) + current.Dispose(); + } + } + + private SafeFileHandle OpenUnixFile(string path, bool create, bool append, bool truncate = false) + { + using var parent = OpenUnixParentDirectory(path, out var rootIdentity); + var flags = UnixWriteOnly | UnixCloseOnExec | UnixNoFollow; + if (append) + flags |= UnixAppend; + if (create) + flags |= UnixCreate; + if (truncate) + flags |= UnixTruncate; + var descriptor = UnixOpenAt( + parent.DangerousGetHandle().ToInt32(), + Path.GetFileName(path), + flags, + (uint)PrivateLogFile.PrivateFileMode); + if (descriptor < 0) + throw CreateNativeIOException(); + + var handle = new SafeFileHandle(new IntPtr(descriptor), ownsHandle: true); + try + { + EnsureUnixObject(handle, rootIdentity, expectedType: UnixRegularFileType); + if (create + && UnixFChmod(handle.DangerousGetHandle().ToInt32(), (uint)PrivateLogFile.PrivateFileMode) != 0) + { + throw CreateNativeIOException(); + } + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + + private SafeFileHandle OpenUnixParentDirectory(string path) + => OpenUnixParentDirectory(path, out _); + + private SafeFileHandle OpenUnixParentDirectory(string path, out UnixBoundaryIdentity rootIdentity) + { + var parentPath = Path.GetDirectoryName(Path.GetFullPath(path)) + ?? throw RepositoryOutputPathBoundary.CreateException(FieldName, "invalid_path"); + return OpenUnixDirectory(parentPath, out rootIdentity); + } + + private SafeFileHandle OpenUnixDirectory(string directory, out UnixBoundaryIdentity rootIdentity) + { + var components = GetRelativeComponents(PathCasing.NormalizeBoundaryPath(directory)); + var current = OpenUnixRootDirectory(out rootIdentity); + try + { + foreach (var component in components) + { + var next = TryOpenUnixDirectoryAt(current, component) ?? throw CreateNativeIOException(); + try + { + EnsureUnixObject(next, rootIdentity, expectedType: UnixDirectoryType); + } + catch + { + next.Dispose(); + throw; + } + current.Dispose(); + current = next; + } + return current; + } + catch + { + current.Dispose(); + throw; + } + } + + private SafeFileHandle OpenUnixRootDirectory(out UnixBoundaryIdentity rootIdentity) + { + var descriptor = UnixOpen( + CanonicalWorkspaceRoot, + UnixReadOnly | UnixCloseOnExec | UnixNoFollow | UnixDirectory); + if (descriptor < 0) + throw CreateNativeIOException(); + + var handle = new SafeFileHandle(new IntPtr(descriptor), ownsHandle: true); + try + { + if (UnixFStat(handle.DangerousGetHandle(), out var status) != 0 + || (status.Mode & UnixFileTypeMask) != UnixDirectoryType) + { + throw CreateNativeIOException(); + } + ulong? mountId = null; + if (RepositoryOutputPathBoundary.RequiresUnixMountIdentity) + { + if (!RepositoryOutputPathBoundary.TryGetUnixMountId(handle, out var resolvedMountId)) + throw CreateNativeIOException(); + mountId = resolvedMountId; + } + rootIdentity = new UnixBoundaryIdentity(status.Device, mountId); + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + + private static SafeFileHandle? TryOpenUnixDirectoryAt(SafeFileHandle parent, string component) + { + var descriptor = UnixOpenAt( + parent.DangerousGetHandle().ToInt32(), + component, + UnixReadOnly | UnixCloseOnExec | UnixNoFollow | UnixDirectory, + mode: 0); + if (descriptor >= 0) + return new SafeFileHandle(new IntPtr(descriptor), ownsHandle: true); + if (Marshal.GetLastWin32Error() == 2) + return null; + throw CreateNativeIOException(); + } + + private static void EnsureUnixObject( + SafeFileHandle handle, + UnixBoundaryIdentity rootIdentity, + int expectedType) + { + if (UnixFStat(handle.DangerousGetHandle(), out var status) != 0 + || (status.Mode & UnixFileTypeMask) != expectedType + || status.Device != rootIdentity.Device) + { + throw CreateNativeIOException(); + } + if (rootIdentity.MountId.HasValue + && (!RepositoryOutputPathBoundary.TryGetUnixMountId(handle, out var mountId) + || mountId != rootIdentity.MountId.Value)) + { + throw CreateNativeIOException(); + } + } + + private string[] GetRelativeComponents(string path) + { + if (!RepositoryOutputPathBoundary.IsPathEqualOrParent(WorkspaceRoot, path)) + throw RepositoryOutputPathBoundary.CreateException(FieldName, "outside_workspace"); + var relative = Path.GetRelativePath(WorkspaceRoot, path); + if (relative == ".") + return []; + var components = relative.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + if (components.Any(static component => component is "." or "..")) + throw RepositoryOutputPathBoundary.CreateException("output path", "outside_workspace"); + return components; + } + + private static RepositoryOutputPathBoundaryException CreateNativeIOException() + => RepositoryOutputPathBoundary.CreateException("output path", RepositoryOutputPathBoundary.UnsafeReason); + + private bool Allows(string path, bool expectsDirectory) + { + if (DestinationIsDirectory) + { + return RepositoryOutputPathBoundary.IsPathEqualOrParent(DestinationPath, path) + || (expectsDirectory + && RepositoryOutputPathBoundary.IsPathEqualOrParent(path, DestinationPath) + && RepositoryOutputPathBoundary.IsPathEqualOrParent(WorkspaceRoot, path)); + } + + if (expectsDirectory && RepositoryOutputPathBoundary.IsPathEqualOrParent(path, DestinationPath)) + return RepositoryOutputPathBoundary.IsPathEqualOrParent(WorkspaceRoot, path); + + if (PathCasing.PathsEqualByDirectoryNamespace(DestinationPath, path)) + return true; + + var destinationDirectory = Path.GetDirectoryName(DestinationPath); + var pathDirectory = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(destinationDirectory) + || string.IsNullOrEmpty(pathDirectory) + || !PathCasing.PathsEqualByDirectoryNamespace( + PathCasing.NormalizeBoundaryPath(destinationDirectory), + PathCasing.NormalizeBoundaryPath(pathDirectory))) + { + return false; + } + + var comparison = PathCasing.ComparisonFor(destinationDirectory); + var destinationName = Path.GetFileName(DestinationPath); + var candidateName = Path.GetFileName(path); + if (!candidateName.StartsWith(destinationName + ".", comparison)) + return false; + + return int.TryParse( + candidateName.AsSpan(destinationName.Length + 1), + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out _); + } + + private SafeFileHandle OpenWindowsPath( + string path, + uint desiredAccess, + uint shareAccess, + uint disposition, + uint attributes, + uint options) + { + using var root = OpenWindowsRootDirectory(); + var relativePath = GetWindowsRelativePath(path); + using var objectName = new WindowsObjectName(root, relativePath); + RepositoryOutputPathBoundary.BeforeWindowsNativeMutationForTesting?.Invoke("open", path); + var status = WindowsNtCreateFile( + out var handle, + desiredAccess, + ref objectName.Attributes, + out _, + IntPtr.Zero, + attributes, + shareAccess, + disposition, + options, + IntPtr.Zero, + 0); + if (status < 0) + { + handle?.Dispose(); + throw CreateNativeIOException(); + } + + try + { + EnsureWindowsHandleUnderRoot(root, handle); + return handle; + } + catch + { + handle.Dispose(); + throw; + } + } + + private SafeFileHandle OpenWindowsRootDirectory() + { + var handle = WindowsCreateFile( + LongPath.EnsureWindowsPrefix(CanonicalWorkspaceRoot), + WindowsFileListDirectory | WindowsFileReadAttributes | WindowsSynchronize, + WindowsShareRead | WindowsShareWrite | WindowsShareDelete, + IntPtr.Zero, + WindowsOpenExisting, + WindowsFileFlagBackupSemantics, + IntPtr.Zero); + if (handle.IsInvalid) + { + handle.Dispose(); + throw CreateNativeIOException(); + } + return handle; + } + + private string GetWindowsRelativePath(string path) + { + var components = GetRelativeComponents(PathCasing.NormalizeBoundaryPath(path)); + if (components.Length == 0) + throw RepositoryOutputPathBoundary.CreateException(FieldName, "invalid_path"); + return string.Join('\\', components); + } + + private static void EnsureWindowsHandleUnderRoot(SafeFileHandle root, SafeFileHandle handle) + { + var rootPath = GetWindowsFinalPath(root); + var handlePath = GetWindowsFinalPath(handle); + if (!RepositoryOutputPathBoundary.IsPathEqualOrParent(rootPath, handlePath)) + throw CreateNativeIOException(); + } + + private static string GetWindowsFinalPath(SafeFileHandle handle) + { + var capacity = 512; + while (capacity <= short.MaxValue) + { + var buffer = new StringBuilder(capacity); + var length = WindowsGetFinalPathNameByHandle(handle, buffer, (uint)buffer.Capacity, flags: 0); + if (length == 0) + throw CreateNativeIOException(); + if (length < buffer.Capacity) + return NormalizeWindowsFinalPath(buffer.ToString()); + capacity = checked((int)length + 1); + } + throw CreateNativeIOException(); + } + + private static string NormalizeWindowsFinalPath(string path) + { + const string extendedUncPrefix = @"\\?\UNC\"; + const string extendedPrefix = @"\\?\"; + if (path.StartsWith(extendedUncPrefix, StringComparison.OrdinalIgnoreCase)) + return @"\\" + path[extendedUncPrefix.Length..]; + if (path.StartsWith(extendedPrefix, StringComparison.OrdinalIgnoreCase)) + return path[extendedPrefix.Length..]; + return path; + } + + private const int UnixReadOnly = 0; + private const int UnixWriteOnly = 1; + private const int UnixFileTypeMask = 0xF000; + private const int UnixDirectoryType = 0x4000; + private const int UnixRegularFileType = 0x8000; + + private readonly record struct UnixBoundaryIdentity(long Device, ulong? MountId); + + private static int UnixCloseOnExec => OperatingSystem.IsMacOS() ? 0x01000000 : 0x00080000; + private static int UnixNoFollow => OperatingSystem.IsMacOS() ? 0x00000100 : 0x00020000; + private static int UnixDirectory => OperatingSystem.IsMacOS() ? 0x00100000 : 0x00010000; + private static int UnixCreate => OperatingSystem.IsMacOS() ? 0x00000200 : 0x00000040; + private static int UnixAppend => OperatingSystem.IsMacOS() ? 0x00000008 : 0x00000400; + private static int UnixTruncate => OperatingSystem.IsMacOS() ? 0x00000400 : 0x00000200; + + private const uint WindowsFileListDirectory = 0x00000001; + private const uint WindowsFileWriteData = 0x00000002; + private const uint WindowsFileAddSubdirectory = 0x00000004; + private const uint WindowsFileAppendData = 0x00000004; + private const uint WindowsFileReadAttributes = 0x00000080; + private const uint WindowsFileWriteAttributes = 0x00000100; + private const uint WindowsDelete = 0x00010000; + private const uint WindowsSynchronize = 0x00100000; + private const uint WindowsShareRead = 0x00000001; + private const uint WindowsShareWrite = 0x00000002; + private const uint WindowsShareDelete = 0x00000004; + private const uint WindowsFileDirectory = 0x00000010; + private const uint WindowsFileNormal = 0x00000080; + private const uint WindowsFileOpen = 0x00000001; + private const uint WindowsFileCreate = 0x00000002; + private const uint WindowsFileOpenIf = 0x00000003; + private const uint WindowsFileOverwriteIf = 0x00000005; + private const uint WindowsFileDirectoryOption = 0x00000001; + private const uint WindowsFileSynchronousIoNonAlert = 0x00000020; + private const uint WindowsFileNonDirectory = 0x00000040; + private const uint WindowsOpenExisting = 3; + private const uint WindowsFileFlagBackupSemantics = 0x02000000; + private const uint WindowsObjectCaseInsensitive = 0x00000040; + private const uint WindowsObjectDontReparse = 0x00001000; + private const int WindowsFileRenameInformation = 10; + private const int WindowsStatusObjectNameNotFound = unchecked((int)0xC0000034); + private const int WindowsStatusObjectPathNotFound = unchecked((int)0xC000003A); + + [DllImport("libc", EntryPoint = "open", SetLastError = true)] + private static extern int UnixOpen(string path, int flags); + + [DllImport("libc", EntryPoint = "openat", SetLastError = true)] + private static extern int UnixOpenAt(int directory, string path, int flags, uint mode); + + [DllImport("libc", EntryPoint = "mkdirat", SetLastError = true)] + private static extern int UnixMkdirAt(int directory, string path, uint mode); + + [DllImport("libc", EntryPoint = "unlinkat", SetLastError = true)] + private static extern int UnixUnlinkAt(int directory, string path, int flags); + + [DllImport("libc", EntryPoint = "renameat", SetLastError = true)] + private static extern int UnixRenameAt( + int sourceDirectory, + string sourcePath, + int destinationDirectory, + string destinationPath); + + [DllImport("libc", EntryPoint = "fchmod", SetLastError = true)] + private static extern int UnixFChmod(int descriptor, uint mode); + + [DllImport("libSystem.Native", EntryPoint = "SystemNative_FStat", SetLastError = true)] + private static extern int UnixFStat(IntPtr descriptor, out UnixFileStatus status); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle WindowsCreateFile( + string path, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint WindowsGetFinalPathNameByHandle( + SafeFileHandle handle, + StringBuilder path, + uint pathLength, + uint flags); + + [DllImport("ntdll.dll", EntryPoint = "NtCreateFile")] + private static extern int WindowsNtCreateFile( + out SafeFileHandle fileHandle, + uint desiredAccess, + ref WindowsObjectAttributes objectAttributes, + out WindowsIoStatusBlock ioStatusBlock, + IntPtr allocationSize, + uint fileAttributes, + uint shareAccess, + uint createDisposition, + uint createOptions, + IntPtr extendedAttributes, + uint extendedAttributesLength); + + [DllImport("ntdll.dll", EntryPoint = "NtDeleteFile")] + private static extern int WindowsNtDeleteFile(ref WindowsObjectAttributes objectAttributes); + + [DllImport("ntdll.dll", EntryPoint = "NtSetInformationFile")] + private static extern int WindowsNtSetInformationFile( + SafeFileHandle fileHandle, + out WindowsIoStatusBlock ioStatusBlock, + IntPtr fileInformation, + uint length, + int fileInformationClass); + + [StructLayout(LayoutKind.Sequential)] + private struct WindowsUnicodeString + { + internal ushort Length; + internal ushort MaximumLength; + internal IntPtr Buffer; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WindowsObjectAttributes + { + internal uint Length; + internal IntPtr RootDirectory; + internal IntPtr ObjectName; + internal uint Attributes; + internal IntPtr SecurityDescriptor; + internal IntPtr SecurityQualityOfService; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WindowsIoStatusBlock + { + internal IntPtr Status; + internal UIntPtr Information; + } + + private sealed class WindowsObjectName : IDisposable + { + private readonly IntPtr _nameBuffer; + private readonly IntPtr _unicodeString; + + internal WindowsObjectName(SafeFileHandle root, string relativePath) + { + var byteLength = checked(relativePath.Length * sizeof(char)); + if (byteLength > ushort.MaxValue - sizeof(char)) + throw RepositoryOutputPathBoundary.CreateException("output path", "invalid_path"); + + _nameBuffer = Marshal.StringToHGlobalUni(relativePath); + _unicodeString = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr( + new WindowsUnicodeString + { + Length = (ushort)byteLength, + MaximumLength = (ushort)(byteLength + sizeof(char)), + Buffer = _nameBuffer, + }, + _unicodeString, + fDeleteOld: false); + Attributes = new WindowsObjectAttributes + { + Length = (uint)Marshal.SizeOf(), + RootDirectory = root.DangerousGetHandle(), + ObjectName = _unicodeString, + Attributes = WindowsObjectCaseInsensitive | WindowsObjectDontReparse, + }; + } + + internal WindowsObjectAttributes Attributes; + + public void Dispose() + { + Marshal.FreeHGlobal(_unicodeString); + Marshal.FreeHGlobal(_nameBuffer); + } + } + + [StructLayout(LayoutKind.Sequential)] + private struct UnixFileStatus + { + internal uint Flags; + internal int Mode; + internal uint Uid; + internal uint Gid; + internal long Size; + internal long ATime; + internal long ATimeNsec; + internal long MTime; + internal long MTimeNsec; + internal long CTime; + internal long CTimeNsec; + internal long BirthTime; + internal long BirthTimeNsec; + internal long Device; + internal long RDevice; + internal long Inode; + internal uint UserFlags; + } +} + +internal sealed class RepositoryOutputPathBoundaryException(string message) : IOException(message); + +internal sealed class UnixAppendStream : Stream +{ + private readonly SafeFileHandle _handle; + private bool _disposed; + + internal UnixAppendStream(SafeFileHandle handle) + { + _handle = handle; + } + + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => !_disposed; + public override long Length + { + get + { + ThrowIfDisposed(); + return RandomAccess.GetLength(_handle); + } + } + + public override long Position + { + get => Length; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + ThrowIfDisposed(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return cancellationToken.IsCancellationRequested + ? Task.FromCanceled(cancellationToken) + : Task.CompletedTask; + } + + public override void Write(byte[] buffer, int offset, int count) + { + ArgumentNullException.ThrowIfNull(buffer); + ArgumentOutOfRangeException.ThrowIfNegative(offset); + ArgumentOutOfRangeException.ThrowIfNegative(count); + if (buffer.Length - offset < count) + throw new ArgumentException("The offset and count exceed the buffer length."); + ThrowIfDisposed(); + if (count == 0) + return; + + if (offset == 0 && count == buffer.Length) + { + WriteAll(buffer, count); + return; + } + + var slice = new byte[count]; + Buffer.BlockCopy(buffer, offset, slice, 0, count); + WriteAll(slice, count); + } + + public override void Write(ReadOnlySpan buffer) + { + if (buffer.IsEmpty) + return; + Write(buffer.ToArray(), 0, buffer.Length); + } + + public override int Read(byte[] buffer, int offset, int count) + => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) + => throw new NotSupportedException(); + + public override void SetLength(long value) + => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing && !_disposed) + { + _disposed = true; + _handle.Dispose(); + } + base.Dispose(disposing); + } + + private void WriteAll(byte[] buffer, int count) + { + var written = 0; + while (written < count) + { + byte[] nextBuffer; + if (written == 0) + { + nextBuffer = buffer; + } + else + { + nextBuffer = new byte[count - written]; + Buffer.BlockCopy(buffer, written, nextBuffer, 0, nextBuffer.Length); + } + + var result = UnixWrite( + _handle.DangerousGetHandle().ToInt32(), + nextBuffer, + (nuint)(count - written)); + if (result > 0) + { + written += checked((int)result); + continue; + } + if (result < 0 && Marshal.GetLastWin32Error() == 4) + continue; + throw new IOException("repository-configured append failed (unsafe_output_path)."); + } + } + + private void ThrowIfDisposed() + => ObjectDisposedException.ThrowIf(_disposed, this); + + [DllImport("libc", EntryPoint = "write", SetLastError = true)] + private static extern nint UnixWrite(int descriptor, byte[] buffer, nuint count); +} diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index eab89d6f9..cca52ead2 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -1,4 +1,5 @@ using CodeIndex.Cli; +using CodeIndex.Indexer; using CodeIndex.Mcp; using System.Text; using System.Text.Json; @@ -282,6 +283,646 @@ public void LoadAndApply_OutputPathOutsideWorkspace_ReturnsError(string key) finally { TestProjectHelper.DeleteDirectory(dir); } } + [Theory] + [InlineData("metrics_path", "bridge/new/metrics.jsonl")] + [InlineData("global_tool_log_dir", "bridge/new/logs")] + public void LoadAndApply_OutputPathUnderSymlinkAncestorIsRejectedWithoutExternalMutation_Issue5181( + string key, + string configuredPath) + { + var workspace = CreateTempDir(); + var outside = CreateTempDir(); + var bridge = Path.Combine(workspace, "bridge"); + try + { + if (!TryCreateDirectoryLink(bridge, outside)) + return; + + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + JsonSerializer.Serialize(new Dictionary { [key] = configuredPath })); + + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + + Assert.True(result.Failed); + Assert.Contains(key, result.Error, StringComparison.Ordinal); + Assert.Contains(RepositoryOutputPathBoundary.UnsafeReason, result.Error, StringComparison.Ordinal); + Assert.DoesNotContain(outside, result.Error, StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(outside, "new"))); + Assert.Empty(result.Settings); + } + finally + { + DeleteLinkEntry(bridge); + TestProjectHelper.DeleteDirectory(workspace); + TestProjectHelper.DeleteDirectory(outside); + } + } + + [Theory] + [InlineData("metrics_path", false, false)] + [InlineData("metrics_path", false, true)] + [InlineData("global_tool_log_dir", true, false)] + [InlineData("global_tool_log_dir", true, true)] + public void LoadAndApply_FinalLinkIsRejectedWithoutChangingTarget_Issue5181( + string key, + bool directoryLink, + bool targetExists) + { + var workspace = CreateTempDir(); + var outside = CreateTempDir(); + var destinationName = directoryLink ? "logs" : "metrics.jsonl"; + var destination = Path.Combine(workspace, destinationName); + var target = Path.Combine(outside, "target"); + UnixFileMode? originalOutsideMode = null; + UnixFileMode? originalTargetMode = null; + try + { + if (targetExists) + { + if (directoryLink) + Directory.CreateDirectory(target); + else + File.WriteAllText(target, "outside-target-content"); + } + if (!OperatingSystem.IsWindows()) + { + originalOutsideMode = File.GetUnixFileMode(outside); + if (targetExists) + originalTargetMode = File.GetUnixFileMode(target); + } + if (!TryCreateLink(destination, target, directoryLink)) + return; + + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + JsonSerializer.Serialize(new Dictionary { [key] = destinationName })); + + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + + Assert.True(result.Failed); + Assert.Contains(key, result.Error, StringComparison.Ordinal); + Assert.Contains(RepositoryOutputPathBoundary.UnsafeReason, result.Error, StringComparison.Ordinal); + Assert.DoesNotContain(outside, result.Error, StringComparison.Ordinal); + if (targetExists && !directoryLink) + Assert.Equal("outside-target-content", File.ReadAllText(target)); + if (!targetExists) + { + Assert.False(File.Exists(target)); + Assert.False(Directory.Exists(target)); + } + if (!OperatingSystem.IsWindows()) + { + Assert.Equal(originalOutsideMode, File.GetUnixFileMode(outside)); + if (targetExists) + Assert.Equal(originalTargetMode, File.GetUnixFileMode(target)); + } + } + finally + { + DeleteLinkEntry(destination); + TestProjectHelper.DeleteDirectory(workspace); + TestProjectHelper.DeleteDirectory(outside); + } + } + + [Fact] + public void LoadAndApply_WorkspaceRootAliasStillAllowsOrdinaryContainedOutput_Issue5181() + { + var container = CreateTempDir(); + var physicalWorkspace = Path.Combine(container, "physical"); + var workspaceAlias = Path.Combine(container, "workspace-alias"); + try + { + Directory.CreateDirectory(physicalWorkspace); + if (!TryCreateDirectoryLink(workspaceAlias, physicalWorkspace)) + return; + + File.WriteAllText( + Path.Combine(physicalWorkspace, CdidxConfigFile.FileName), + """{ "metrics_path": "safe/metrics.jsonl" }"""); + + var result = CdidxConfigFile.Load(workspaceAlias, new TestEnvironment().Read); + + Assert.True(result.Loaded); + Assert.Null(result.Error); + Assert.Equal( + Path.Combine(workspaceAlias, "safe", "metrics.jsonl"), + result.Settings[MetricsSink.EnvVarName]); + using var environment = CdidxEnvironment.Push(result.Settings, result.Sources); + using var session = MetricsSink.TryStartForTesting(explicitPath: null, maxBytes: 1024 * 1024); + Assert.NotNull(session); + MetricsSink.Record(new MetricsEvent( + Timestamp: DateTimeOffset.UtcNow, + Tool: "status", + Source: "cli", + ElapsedMs: 1, + ExitCode: 0)); + Assert.True(session.WaitForIdle(TimeSpan.FromSeconds(5))); + Assert.Contains( + "\"tool\":\"status\"", + File.ReadAllText(Path.Combine(physicalWorkspace, "safe", "metrics.jsonl")), + StringComparison.Ordinal); + } + finally + { + DeleteLinkEntry(workspaceAlias); + TestProjectHelper.DeleteDirectory(container); + } + } + + [Fact] + public void OutputBoundary_WindowsWorkspaceRootAliasRetargetUsesOriginalTarget_Issue5181Review() + { + if (!OperatingSystem.IsWindows()) + return; + + var container = CreateTempDir(); + var physicalWorkspace = Path.Combine(container, "physical"); + var workspaceAlias = Path.Combine(container, "workspace-alias"); + var outside = CreateTempDir(); + try + { + Directory.CreateDirectory(physicalWorkspace); + if (!TryCreateDirectoryLink(workspaceAlias, physicalWorkspace)) + return; + + File.WriteAllText( + Path.Combine(physicalWorkspace, CdidxConfigFile.FileName), + """{ "metrics_path": "safe/metrics.jsonl" }"""); + var result = CdidxConfigFile.Load(workspaceAlias, new TestEnvironment().Read); + Assert.True(result.Loaded); + using var environment = CdidxEnvironment.Push(result.Settings, result.Sources); + var metricsPath = result.Settings[MetricsSink.EnvVarName]; + var boundary = RepositoryOutputPathBoundary.CreateGuardForConfigSource( + MetricsSink.EnvVarName, + "metrics_path", + metricsPath, + destinationIsDirectory: false); + Assert.NotNull(boundary); + var swapped = false; + RepositoryOutputPathBoundary.BeforeWindowsNativeMutationForTesting = (operation, _) => + { + if (swapped || operation != "open") + return; + + DeleteLinkEntry(workspaceAlias); + Assert.True(TryCreateDirectoryLink(workspaceAlias, outside)); + swapped = true; + }; + + boundary!.CreateSensitiveDestinationDirectory(); + + Assert.True(swapped); + Assert.True(Directory.Exists(Path.Combine(physicalWorkspace, "safe"))); + Assert.False(Directory.Exists(Path.Combine(outside, "safe"))); + } + finally + { + RepositoryOutputPathBoundary.BeforeWindowsNativeMutationForTesting = null; + DeleteLinkEntry(workspaceAlias); + TestProjectHelper.DeleteDirectory(container); + TestProjectHelper.DeleteDirectory(outside); + } + } + + [Fact] + public void OutputBoundary_RejectsCaseOnlySiblingInDistinctParentNamespace_Issue5181Review() + { + var root = CreateTempDir(); + var upperWorkspace = Path.Combine(root, "Workspace"); + var lowerWorkspace = Path.Combine(root, "workspace"); + var configuredPath = Path.Combine(lowerWorkspace, "logs", "metrics.jsonl"); + var rootIdentity = new FileIndexer.FileIdentity(1, 1); + var upperIdentity = new FileIndexer.FileIdentity(2, 2); + var lowerIdentity = new FileIndexer.FileIdentity(3, 3); + try + { + Directory.CreateDirectory(upperWorkspace); + FileIndexer.FileIdentity? Identity(string path) + { + var fullPath = Path.GetFullPath(path); + if (string.Equals(fullPath, root, StringComparison.Ordinal)) + return rootIdentity; + if (string.Equals(fullPath, upperWorkspace, StringComparison.Ordinal)) + return upperIdentity; + if (string.Equals(fullPath, lowerWorkspace, StringComparison.Ordinal)) + return lowerIdentity; + return null; + } + + bool IgnoreCase(string path) + => !string.Equals(Path.GetFullPath(path), root, StringComparison.Ordinal); + + RepositoryOutputPathBoundary.ContainsPathForTesting = (parent, child) => + PathCasing.IsPathEqualOrParentByDirectoryNamespaceForTesting( + parent, + child, + IgnoreCase, + Identity); + + var accepted = RepositoryOutputPathBoundary.TryResolveConfiguredPath( + configuredPath, + upperWorkspace, + destinationIsDirectory: false, + out _, + out var failureReason); + + Assert.False(accepted); + Assert.Equal("outside_workspace", failureReason); + } + finally + { + RepositoryOutputPathBoundary.ContainsPathForTesting = null; + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void OutputBoundary_RejectsSameDeviceDistinctUnixMountIdentity_Issue5181Review() + { + if (OperatingSystem.IsWindows()) + return; + + var workspace = CreateTempDir(); + var mountedDirectory = Path.Combine(workspace, "mounted"); + try + { + Directory.CreateDirectory(mountedDirectory); + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "mounted/new/metrics.jsonl" }"""); + RepositoryOutputPathBoundary.UnixMountIdForTesting = path => + string.Equals( + Path.GetFullPath(path), + mountedDirectory, + StringComparison.Ordinal) + ? 2UL + : 1UL; + + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + + Assert.True(result.Failed); + Assert.Contains(RepositoryOutputPathBoundary.UnsafeReason, result.Error, StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(mountedDirectory, "new"))); + Assert.Empty(result.Settings); + } + finally + { + RepositoryOutputPathBoundary.UnixMountIdForTesting = null; + TestProjectHelper.DeleteDirectory(workspace); + } + } + + [Theory] + [InlineData("pos:\t0\nflags:\t0100000\nmnt_id:\t42\nino:\t123\n", 42UL)] + [InlineData("mnt_id: 18446744073709551615\n", ulong.MaxValue)] + public void OutputBoundary_ParsesLinuxFdInfoMountIdentityFallback_Issue5181Review( + string fdInfo, + ulong expectedMountId) + { + var parsed = RepositoryOutputPathBoundary.TryParseLinuxFdInfoMountId( + fdInfo, + out var mountId); + + Assert.True(parsed); + Assert.Equal(expectedMountId, mountId); + } + + [Theory] + [InlineData("")] + [InlineData("pos:\t0\nino:\t123\n")] + [InlineData("mnt_id:\tnot-a-number\n")] + [InlineData("mnt_id:\t18446744073709551616\n")] + public void OutputBoundary_RejectsInvalidLinuxFdInfoMountIdentityFallback_Issue5181Review( + string fdInfo) + { + Assert.False( + RepositoryOutputPathBoundary.TryParseLinuxFdInfoMountId(fdInfo, out _)); + } + + [Fact] + public void OutputBoundary_UsesLinuxFdInfoWhenStatxMountIdentityIsUnavailable_Issue5181Review() + { + if (!OperatingSystem.IsLinux()) + return; + + var workspace = CreateTempDir(); + try + { + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "logs/metrics.jsonl" }"""); + RepositoryOutputPathBoundary.UnixMountIdForTesting = _ => null; + + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + + Assert.True(result.Loaded); + Assert.Equal( + Path.Combine(workspace, "logs", "metrics.jsonl"), + result.Settings[MetricsSink.EnvVarName]); + } + finally + { + RepositoryOutputPathBoundary.UnixMountIdForTesting = null; + TestProjectHelper.DeleteDirectory(workspace); + } + } + + [Fact] + public void OutputBoundary_RejectsDistinctMountIdentityFromOpenedAncestor_Issue5181Review() + { + if (OperatingSystem.IsWindows()) + return; + + var workspace = CreateTempDir(); + var safeDirectory = Path.Combine(workspace, "safe"); + var metricsPath = Path.Combine(safeDirectory, "metrics.jsonl"); + try + { + Directory.CreateDirectory(safeDirectory); + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "safe/metrics.jsonl" }"""); + RepositoryOutputPathBoundary.UnixMountIdForTesting = _ => 1UL; + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + Assert.True(result.Loaded); + using var environment = CdidxEnvironment.Push(result.Settings, result.Sources); + var boundary = RepositoryOutputPathBoundary.CreateGuardForConfigSource( + MetricsSink.EnvVarName, + "metrics_path", + metricsPath, + destinationIsDirectory: false); + Assert.NotNull(boundary); + var handleProbeCount = 0; + RepositoryOutputPathBoundary.UnixHandleMountIdForTesting = _ => + Interlocked.Increment(ref handleProbeCount) == 1 ? 1UL : 2UL; + + var exception = Assert.Throws( + () => PrivateLogFile.OpenAppend(metricsPath, boundary: boundary)); + + Assert.Contains(RepositoryOutputPathBoundary.UnsafeReason, exception.Message, StringComparison.Ordinal); + Assert.Equal(2, handleProbeCount); + Assert.False(File.Exists(metricsPath)); + } + finally + { + RepositoryOutputPathBoundary.UnixHandleMountIdForTesting = null; + RepositoryOutputPathBoundary.UnixMountIdForTesting = null; + TestProjectHelper.DeleteDirectory(workspace); + } + } + + [Fact] + public void OutputBoundary_GuardedRenameSurfacesParentFsyncFailure_Issue5181Review() + { + if (OperatingSystem.IsWindows()) + return; + + var workspace = CreateTempDir(); + var metricsPath = Path.Combine(workspace, "logs", "metrics.jsonl"); + Exception? rotationFailure = null; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(metricsPath)!); + File.WriteAllText(metricsPath, "current"); + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "logs/metrics.jsonl" }"""); + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + Assert.True(result.Loaded); + using var environment = CdidxEnvironment.Push(result.Settings, result.Sources); + var boundary = RepositoryOutputPathBoundary.CreateGuardForConfigSource( + MetricsSink.EnvVarName, + "metrics_path", + metricsPath, + destinationIsDirectory: false); + Assert.NotNull(boundary); + var fsyncCount = 0; + RepositoryOutputPathBoundary.UnixDirectoryFsyncForTesting = _ => + { + fsyncCount++; + return -1; + }; + + var rotated = PrivateLogFile.TryRotateSlots( + metricsPath, + retainedFileCount: 2, + onFailure: ex => rotationFailure = ex, + boundary: boundary); + + Assert.False(rotated); + var durabilityFailure = Assert.IsType(rotationFailure); + Assert.IsNotType(durabilityFailure); + Assert.Contains("already replaced", durabilityFailure.Message, StringComparison.Ordinal); + Assert.Contains("parent directory could not be flushed", durabilityFailure.Message, StringComparison.Ordinal); + Assert.Equal(1, fsyncCount); + Assert.False(File.Exists(metricsPath)); + Assert.Equal("current", File.ReadAllText(metricsPath + ".1")); + } + finally + { + RepositoryOutputPathBoundary.UnixDirectoryFsyncForTesting = null; + TestProjectHelper.DeleteDirectory(workspace); + } + } + + [Fact] + public void OutputBoundary_RebindsParentAfterAncestorRenameAndReplacement_Issue5181Review() + { + if (OperatingSystem.IsWindows()) + return; + + var workspace = CreateTempDir(); + var outside = CreateTempDir(); + var safeDirectory = Path.Combine(workspace, "safe"); + var movedDirectory = Path.Combine(outside, "moved"); + var metricsPath = Path.Combine(safeDirectory, "new", "metrics.jsonl"); + try + { + Directory.CreateDirectory(safeDirectory); + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "safe/new/metrics.jsonl" }"""); + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + Assert.True(result.Loaded); + using var environment = CdidxEnvironment.Push(result.Settings, result.Sources); + var boundary = RepositoryOutputPathBoundary.CreateGuardForConfigSource( + MetricsSink.EnvVarName, + "metrics_path", + metricsPath, + destinationIsDirectory: false); + Assert.NotNull(boundary); + var swapped = false; + RepositoryOutputPathBoundary.BeforeMutationForTesting = (operation, _) => + { + if (swapped || operation != "create_directory") + return; + swapped = true; + Directory.Move(safeDirectory, movedDirectory); + Directory.CreateDirectory(safeDirectory); + }; + + boundary!.CreateSensitiveDestinationDirectory(); + + Assert.True(swapped); + Assert.True(Directory.Exists(Path.Combine(safeDirectory, "new"))); + Assert.False(Directory.Exists(Path.Combine(movedDirectory, "new"))); + } + finally + { + RepositoryOutputPathBoundary.BeforeMutationForTesting = null; + TestProjectHelper.DeleteDirectory(workspace); + TestProjectHelper.DeleteDirectory(outside); + } + } + + [Fact] + public void OutputBoundary_WindowsNativeOpenRejectsJunctionSwapAfterPrepare_Issue5181Review() + { + if (!OperatingSystem.IsWindows()) + return; + + var workspace = CreateTempDir(); + var outside = CreateTempDir(); + var safeDirectory = Path.Combine(workspace, "safe"); + var originalDirectory = Path.Combine(workspace, "safe-original"); + var metricsPath = Path.Combine(safeDirectory, "new", "metrics.jsonl"); + var probeLink = Path.Combine(workspace, "junction-probe"); + try + { + if (!TryCreateDirectoryLink(probeLink, outside)) + return; + DeleteLinkEntry(probeLink); + Directory.CreateDirectory(safeDirectory); + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "safe/new/metrics.jsonl" }"""); + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + Assert.True(result.Loaded); + using var environment = CdidxEnvironment.Push(result.Settings, result.Sources); + var boundary = RepositoryOutputPathBoundary.CreateGuardForConfigSource( + MetricsSink.EnvVarName, + "metrics_path", + metricsPath, + destinationIsDirectory: false); + Assert.NotNull(boundary); + var swapped = false; + RepositoryOutputPathBoundary.BeforeWindowsNativeMutationForTesting = (operation, path) => + { + if (swapped + || operation != "open" + || !PathCasing.PathsEqualByDirectoryNamespace(path, safeDirectory)) + { + return; + } + Directory.Move(safeDirectory, originalDirectory); + Directory.CreateSymbolicLink(safeDirectory, outside); + swapped = true; + }; + + var exception = Assert.Throws( + () => boundary!.CreateSensitiveDestinationDirectory()); + + Assert.True(swapped); + Assert.Contains(RepositoryOutputPathBoundary.UnsafeReason, exception.Message, StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(outside, "new"))); + Assert.False(File.Exists(Path.Combine(outside, "new", "metrics.jsonl"))); + } + finally + { + RepositoryOutputPathBoundary.BeforeWindowsNativeMutationForTesting = null; + DeleteLinkEntry(safeDirectory); + TestProjectHelper.DeleteDirectory(workspace); + TestProjectHelper.DeleteDirectory(outside); + } + } + + [Fact] + public void LoadAndApply_OutputPathAtUnixFifoIsRejectedBeforeOpen_Issue5181Review() + { + if (OperatingSystem.IsWindows()) + return; + + var workspace = CreateTempDir(); + var fifoPath = Path.Combine(workspace, "metrics.jsonl"); + try + { + try + { + if (Mkfifo(fifoPath, 0x180) != 0) + return; + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + return; + } + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "metrics.jsonl" }"""); + + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + + Assert.True(result.Failed); + Assert.Contains(RepositoryOutputPathBoundary.UnsafeReason, result.Error, StringComparison.Ordinal); + Assert.Empty(result.Settings); + } + finally + { + TestProjectHelper.DeleteDirectory(workspace); + } + } + + [Fact] + public void RepositoryOutputBoundary_RevalidatesAfterInjectedAncestorSwap_Issue5181() + { + var workspace = CreateTempDir(); + var outside = CreateTempDir(); + var safeDirectory = Path.Combine(workspace, "safe"); + var configuredPath = Path.Combine(safeDirectory, "new", "metrics.jsonl"); + try + { + Directory.CreateDirectory(safeDirectory); + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "safe/new/metrics.jsonl" }"""); + var result = CdidxConfigFile.Load(workspace, new TestEnvironment().Read); + Assert.True(result.Loaded); + + using var environment = CdidxEnvironment.Push(result.Settings, result.Sources); + var boundary = RepositoryOutputPathBoundary.CreateGuardForConfigSource( + MetricsSink.EnvVarName, + "metrics_path", + configuredPath, + destinationIsDirectory: false); + Assert.NotNull(boundary); + + var swapped = false; + RepositoryOutputPathBoundary.BeforeMutationForTesting = (operation, _) => + { + if (swapped || operation != "create_directory") + return; + swapped = true; + Directory.Delete(safeDirectory); + Directory.CreateSymbolicLink(safeDirectory, outside); + }; + + var exception = Assert.Throws( + () => boundary!.CreateSensitiveDestinationDirectory()); + + Assert.Contains(RepositoryOutputPathBoundary.UnsafeReason, exception.Message, StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(outside, "new"))); + Assert.False(File.Exists(Path.Combine(outside, "new", "metrics.jsonl"))); + } + finally + { + RepositoryOutputPathBoundary.BeforeMutationForTesting = null; + DeleteLinkEntry(safeDirectory); + TestProjectHelper.DeleteDirectory(workspace); + TestProjectHelper.DeleteDirectory(outside); + } + } + [Theory] [InlineData("""{ "search": { "limit": 0 } }""", "positive integer")] [InlineData("""{ "search": { "snippet_lines": -1 } }""", "positive integer")] @@ -1098,9 +1739,48 @@ private static string CreateTempDir() return TestProjectHelper.CreateTempProject("cdidx_config"); } + private static bool TryCreateDirectoryLink(string linkPath, string targetPath) + => TryCreateLink(linkPath, targetPath, directoryLink: true); + + private static bool TryCreateLink(string linkPath, string targetPath, bool directoryLink) + { + try + { + if (directoryLink) + Directory.CreateSymbolicLink(linkPath, targetPath); + else + File.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + return false; + } + } + + private static void DeleteLinkEntry(string path) + { + try + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReparsePoint) == 0) + return; + if ((attributes & FileAttributes.Directory) != 0) + Directory.Delete(path); + else + File.Delete(path); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + } + } + private static (int ExitCode, string Stdout, string Stderr) CaptureConsole(Func action) => ConsoleCapture.Capture(action); + [System.Runtime.InteropServices.DllImport("libc", EntryPoint = "mkfifo", SetLastError = true)] + private static extern int Mkfifo(string path, uint mode); + private sealed class TestEnvironment { private readonly Dictionary _env; diff --git a/tests/CodeIndex.Tests/GlobalToolLogTests.cs b/tests/CodeIndex.Tests/GlobalToolLogTests.cs index d7eda2475..51f26bdfe 100644 --- a/tests/CodeIndex.Tests/GlobalToolLogTests.cs +++ b/tests/CodeIndex.Tests/GlobalToolLogTests.cs @@ -69,6 +69,219 @@ public void PrivateLogFile_OpenAppend_OnUnixRejectsSymlinkTargets_Issue3824() } } + [Fact] + public void TryStart_RepositoryConfiguredOrdinaryDirectoryWritesSuccessfully_Issue5181() + { + var workspace = TestProjectHelper.CreateTempProject("cdidx_global_log_config_5181"); + var logDirectory = Path.Combine(workspace, "state", "logs"); + var sourceVariable = CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + "CDIDX_GLOBAL_TOOL_LOG_DIR"; + using var environment = EnvironmentVariableScope.Capture( + "CDIDX_FORCE_GLOBAL_TOOL_LOG", + "CDIDX_DISABLE_PERSISTENT_LOG", + "CDIDX_GLOBAL_TOOL_LOG_DIR", + sourceVariable); + environment.Set("CDIDX_FORCE_GLOBAL_TOOL_LOG", "1"); + environment.Set("CDIDX_DISABLE_PERSISTENT_LOG", null); + environment.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", null); + environment.Set(sourceVariable, null); + try + { + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "global_tool_log_dir": "state/logs" }"""); + var config = CdidxConfigFile.Load(workspace); + Assert.True(config.Loaded); + + using (CdidxEnvironment.Push(config.Settings, config.Sources)) + using (var session = GlobalToolLog.TryStartForTesting(["status"], "test")) + { + Assert.NotNull(session); + GlobalToolLog.Info("repository_configured_log_boundary_ok"); + } + + var logPath = Assert.Single(Directory.GetFiles(logDirectory, "stderr-*.log")); + Assert.Contains("repository_configured_log_boundary_ok", File.ReadAllText(logPath), StringComparison.Ordinal); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(logDirectory) & PermissionBits); + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(logPath) & PermissionBits); + } + } + finally + { + TestProjectHelper.DeleteDirectory(workspace); + } + } + + [Fact] + public void TryStart_RepositoryConfiguredAncestorSwapIsRejectedWithoutFallbackWrite_Issue5181() + { + var workspace = TestProjectHelper.CreateTempProject("cdidx_global_log_race_5181"); + var outside = TestProjectHelper.CreateTempProject("cdidx_global_log_outside_5181"); + var safeDirectory = Path.Combine(workspace, "safe"); + var sourceVariable = CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + "CDIDX_GLOBAL_TOOL_LOG_DIR"; + using var environment = EnvironmentVariableScope.Capture( + "CDIDX_FORCE_GLOBAL_TOOL_LOG", + "CDIDX_DISABLE_PERSISTENT_LOG", + "CDIDX_GLOBAL_TOOL_LOG_DIR", + sourceVariable); + environment.Set("CDIDX_FORCE_GLOBAL_TOOL_LOG", "1"); + environment.Set("CDIDX_DISABLE_PERSISTENT_LOG", null); + environment.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", null); + environment.Set(sourceVariable, null); + try + { + Directory.CreateDirectory(safeDirectory); + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "global_tool_log_dir": "safe/new/logs" }"""); + var config = CdidxConfigFile.Load(workspace); + Assert.True(config.Loaded); + var swapped = false; + RepositoryOutputPathBoundary.BeforeMutationForTesting = (operation, _) => + { + if (swapped || operation != "create_directory") + return; + swapped = true; + Directory.Delete(safeDirectory); + Directory.CreateSymbolicLink(safeDirectory, outside); + }; + + using var scopedConfig = CdidxEnvironment.Push(config.Settings, config.Sources); + var capture = ConsoleCapture.Capture(() => + { + using var session = GlobalToolLog.TryStartForTesting(["status"], "test"); + return session is null ? 0 : 1; + }); + + Assert.Equal(0, capture.ExitCode); + Assert.Contains("global_tool_log_dir", capture.Stderr, StringComparison.Ordinal); + Assert.Contains(RepositoryOutputPathBoundary.UnsafeReason, capture.Stderr, StringComparison.Ordinal); + Assert.False(Directory.Exists(Path.Combine(outside, "new"))); + Assert.Empty(Directory.GetFiles(outside, "stderr-*.log", SearchOption.AllDirectories)); + } + finally + { + RepositoryOutputPathBoundary.BeforeMutationForTesting = null; + DeleteDirectoryLink(safeDirectory); + TestProjectHelper.DeleteDirectory(workspace); + TestProjectHelper.DeleteDirectory(outside); + } + } + + [Fact] + public void QueryTrace_RepositoryConfiguredAncestorSwapDoesNotWriteOutsideWorkspace_Issue5181() + { + var workspace = TestProjectHelper.CreateTempProject("cdidx_query_trace_race_5181"); + var outside = TestProjectHelper.CreateTempProject("cdidx_query_trace_outside_5181"); + var safeDirectory = Path.Combine(workspace, "safe"); + var originalDirectory = Path.Combine(workspace, "safe-original"); + var logDirectory = Path.Combine(safeDirectory, "logs"); + var sourceVariable = CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + "CDIDX_GLOBAL_TOOL_LOG_DIR"; + using var environment = EnvironmentVariableScope.Capture("CDIDX_GLOBAL_TOOL_LOG_DIR", sourceVariable); + environment.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", null); + environment.Set(sourceVariable, null); + try + { + Directory.CreateDirectory(logDirectory); + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "global_tool_log_dir": "safe/logs" }"""); + var config = CdidxConfigFile.Load(workspace); + Assert.True(config.Loaded); + var swapped = false; + RepositoryOutputPathBoundary.BeforeMutationForTesting = (operation, path) => + { + if (swapped + || operation != "open_append" + || !Path.GetFileName(path).StartsWith("query-trace-", StringComparison.Ordinal)) + { + return; + } + + swapped = true; + Directory.Move(safeDirectory, originalDirectory); + Directory.CreateSymbolicLink(safeDirectory, outside); + }; + + using var scopedConfig = CdidxEnvironment.Push(config.Settings, config.Sources); + ProgramRunner.EmitQueryTrace( + "file", + "search", + ["needle"], + DateTimeOffset.UtcNow, + System.Diagnostics.Stopwatch.StartNew(), + CommandExitCodes.Success, + resultCount: 0); + + Assert.True(swapped); + Assert.Empty(Directory.GetFiles(outside, "query-trace-*.jsonl", SearchOption.AllDirectories)); + } + finally + { + RepositoryOutputPathBoundary.BeforeMutationForTesting = null; + DeleteDirectoryLink(safeDirectory); + TestProjectHelper.DeleteDirectory(workspace); + TestProjectHelper.DeleteDirectory(outside); + } + } + + [Fact] + public void LastFailure_RepositoryConfiguredAncestorSwapDoesNotWriteOutsideWorkspace_Issue5181() + { + var workspace = TestProjectHelper.CreateTempProject("cdidx_last_failure_race_5181"); + var outside = TestProjectHelper.CreateTempProject("cdidx_last_failure_outside_5181"); + var safeDirectory = Path.Combine(workspace, "safe"); + var originalDirectory = Path.Combine(workspace, "safe-original"); + var logDirectory = Path.Combine(safeDirectory, "logs"); + var sourceVariable = CdidxConfigFile.ConfigSourceEnvironmentVariablePrefix + "CDIDX_GLOBAL_TOOL_LOG_DIR"; + using var environment = EnvironmentVariableScope.Capture("CDIDX_GLOBAL_TOOL_LOG_DIR", sourceVariable); + environment.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", null); + environment.Set(sourceVariable, null); + try + { + Directory.CreateDirectory(logDirectory); + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "global_tool_log_dir": "safe/logs" }"""); + var config = CdidxConfigFile.Load(workspace); + Assert.True(config.Loaded); + var swapped = false; + RepositoryOutputPathBoundary.BeforeMutationForTesting = (operation, _) => + { + if (swapped || operation != "write_private_text") + return; + + swapped = true; + Directory.Move(safeDirectory, originalDirectory); + Directory.CreateSymbolicLink(safeDirectory, outside); + }; + + using var scopedConfig = CdidxEnvironment.Push(config.Settings, config.Sources); + var persisted = LastFailureEventStore.TryPersist( + ["search"], + "test", + CommandExitCodes.UnhandledException, + new InvalidOperationException("test failure"), + DateTimeOffset.UtcNow, + dbPathForTesting: Path.Combine(workspace, ".cdidx", "codeindex.db"), + workspacePathForTesting: workspace); + + Assert.False(persisted); + Assert.True(swapped); + Assert.False(File.Exists(Path.Combine(outside, LastFailureEventStore.FileName))); + } + finally + { + RepositoryOutputPathBoundary.BeforeMutationForTesting = null; + DeleteDirectoryLink(safeDirectory); + TestProjectHelper.DeleteDirectory(workspace); + TestProjectHelper.DeleteDirectory(outside); + } + } + [Fact] public void PrivateLogFile_HardenExisting_CapsBestEffortWork_Issue3027() { @@ -746,6 +959,18 @@ public void TryStart_ErrorMirrorTruncatesLargeWrites_Issue3166() private static void ThrowForGlobalToolLogTest() => throw new InvalidOperationException("global log stack trace test"); + private static void DeleteDirectoryLink(string path) + { + try + { + if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) + Directory.Delete(path); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + } + } + private sealed class ThrowingTextWriter : TextWriter { public override Encoding Encoding => Encoding.UTF8; diff --git a/tests/CodeIndex.Tests/MetricsSinkTests.cs b/tests/CodeIndex.Tests/MetricsSinkTests.cs index 5fada4d5b..1323d6117 100644 --- a/tests/CodeIndex.Tests/MetricsSinkTests.cs +++ b/tests/CodeIndex.Tests/MetricsSinkTests.cs @@ -170,6 +170,130 @@ public void Run_WithMetricsFlag_OnUnixCreatesPrivateParentDirectory_Issue3686() } } + [Fact] + public void Record_RepositoryConfiguredOrdinaryPathWritesSuccessfully_Issue5181() + { + var workspace = TestProjectHelper.CreateTempProject("cdidx_metrics_config_5181"); + var metricsPath = Path.Combine(workspace, "logs", "metrics.jsonl"); + var configPath = Path.Combine(workspace, CdidxConfigFile.FileName); + var warnings = new List(); + try + { + File.WriteAllText(configPath, """{ "metrics_path": "logs/metrics.jsonl" }"""); + var config = CdidxConfigFile.Load(workspace); + Assert.True(config.Loaded); + using var environment = CdidxEnvironment.Push(config.Settings, config.Sources); + using var session = MetricsSink.TryStartForTesting( + explicitPath: null, + maxBytes: 1024 * 1024, + warningSink: warnings.Add); + Assert.NotNull(session); + var boundary = RepositoryOutputPathBoundary.CreateGuardForConfigSource( + MetricsSink.EnvVarName, + "metrics_path", + metricsPath, + destinationIsDirectory: false); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(metricsPath)); + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(Path.GetDirectoryName(metricsPath)!)); + } + using (var stream = PrivateLogFile.OpenAppend(metricsPath, boundary: boundary)) + { + stream.WriteByte((byte)'\n'); + } + + MetricsSink.Record(new MetricsEvent( + Timestamp: DateTimeOffset.UtcNow, + Tool: "status", + Source: "cli", + ElapsedMs: 1, + ExitCode: 0)); + + Assert.True(session.WaitForIdle(TimeSpan.FromSeconds(5))); + var diagnostics = session.SnapshotDiagnostics(); + Assert.False(diagnostics.Degraded, diagnostics.LastFailure); + Assert.Empty(warnings); + Assert.Contains("\"tool\":\"status\"", File.ReadAllText(metricsPath), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(workspace); + } + } + + [Fact] + public void Record_RepositoryConfiguredPathRotatesWithinWorkspace_Issue5181() + { + var workspace = TestProjectHelper.CreateTempProject("cdidx_metrics_config_rotate_5181"); + var metricsPath = Path.Combine(workspace, "logs", "metrics.jsonl"); + try + { + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "logs/metrics.jsonl" }"""); + var config = CdidxConfigFile.Load(workspace); + Assert.True(config.Loaded); + using var environment = CdidxEnvironment.Push(config.Settings, config.Sources); + using var session = MetricsSink.TryStartForTesting(explicitPath: null, maxBytes: 1); + Assert.NotNull(session); + + MetricsSink.Record(new MetricsEvent( + Timestamp: DateTimeOffset.UtcNow, + Tool: "status", + Source: "cli", + ElapsedMs: 1, + ExitCode: 0)); + + Assert.True(session.WaitForIdle(TimeSpan.FromSeconds(5))); + var diagnostics = session.SnapshotDiagnostics(); + Assert.False(diagnostics.Degraded, diagnostics.LastFailure); + Assert.True(File.Exists(metricsPath + ".1")); + Assert.Contains("\"tool\":\"status\"", File.ReadAllText(metricsPath + ".1"), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(workspace); + } + } + + [Fact] + public void Record_ExplicitPathRetainsOperatorControlledBehavior_Issue5181() + { + var workspace = TestProjectHelper.CreateTempProject("cdidx_metrics_config_explicit_5181"); + var explicitDirectory = TestProjectHelper.CreateTempProject("cdidx_metrics_explicit_5181"); + var explicitPath = Path.Combine(explicitDirectory, "metrics.jsonl"); + try + { + File.WriteAllText( + Path.Combine(workspace, CdidxConfigFile.FileName), + """{ "metrics_path": "logs/config-metrics.jsonl" }"""); + var config = CdidxConfigFile.Load(workspace); + Assert.True(config.Loaded); + using var environment = CdidxEnvironment.Push(config.Settings, config.Sources); + using var session = MetricsSink.TryStartForTesting(explicitPath, maxBytes: 1024 * 1024); + Assert.NotNull(session); + + MetricsSink.Record(new MetricsEvent( + Timestamp: DateTimeOffset.UtcNow, + Tool: "status", + Source: "cli", + ElapsedMs: 1, + ExitCode: 0)); + + Assert.True(session.WaitForIdle(TimeSpan.FromSeconds(5))); + Assert.Contains("\"tool\":\"status\"", File.ReadAllText(explicitPath), StringComparison.Ordinal); + Assert.False(File.Exists(Path.Combine(workspace, "logs", "config-metrics.jsonl"))); + } + finally + { + TestProjectHelper.DeleteDirectory(workspace); + TestProjectHelper.DeleteDirectory(explicitDirectory); + } + } + [Fact] public void Record_RotatesMetricsLogAtMaxBytes() {