diff --git a/README.md b/README.md index 2f13f8e2b9..950f88246a 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,8 @@ downgrading `cdidx`. | Freshness | Parallel full-scan extraction with `--parallelism`, incremental refreshes with `--files` and `--commits`, continuous `--watch`, exact `status --check`, and configurable stale thresholds via `--stale-after` / `CDIDX_STALE_AFTER`. | | Storage | Local-first `.cdidx/codeindex.db` storage. Query commands run from nested directories prefer the outermost ancestor `.cdidx/codeindex.db` before falling back to the current directory. `--data-dir `, `CDIDX_DATA_DIR`, or `XDG_DATA_HOME` can move default SQLite storage outside the workspace; explicit `--db ` still wins. | | DB maintenance | New indexes use SQLite incremental auto-vacuum. `cdidx vacuum` reclaims free pages from existing DBs, including a one-time full `VACUUM` conversion for legacy no-autovacuum DBs, and `status --json` reports metrics under `db_pragma_settings`. | -| Security defaults | On POSIX systems, `.cdidx` is created with `0700` permissions and `status --json` reports the effective `data_dir_mode` when available. | -| Diagnostics | `doctor` prints a redacted environment summary for bug reports. `status --config` prints effective configuration with source attribution, and `status --explain ` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms `, and --trace=stderr|file|none; file traces write daily `query-trace-YYYYMMDD.jsonl` files next to the lifecycle log. | +| Security defaults | On POSIX systems, `.cdidx` is created with `0700` permissions, lifecycle, metrics, MCP audit, and query trace logs are created owner-read/write from the start, metrics and audit logs rotate to bounded slots, query trace logs are pruned to a bounded retained set, and `status --json` reports the effective `data_dir_mode` when available. | +| Diagnostics | `doctor` prints a redacted environment summary for bug reports. `status --config` prints effective configuration with source attribution, and `status --explain ` describes readiness fields and remediation. Read commands support `--profile`, `--slow-query-ms `, and --trace=stderr|file|none; file traces write daily `query-trace-YYYYMMDD.jsonl` files next to the lifecycle log and retain the newest 30 trace files. | | Query exit codes | Valid zero-result query commands exit `0` by default. Pass `--strict-not-found` when scripts should treat zero rows as exit code `2`. | | Drift checks | `cdidx diff ` compares schema, file, symbol, and reference deltas with stable exit codes: `0` identical, `1` drift, `2` schema mismatch, `3` unreadable DB. | | Extensibility and feedback | Post-extraction hooks from `~/.config/cdidx/hooks/*.dll` or `CDIDX_HOOKS_DIR` can enrich symbols and references. `cdidx suggestions` lists, inspects, and exports local suggestion history, with fuzzy MCP suggestion deduplication controlled by CLI, env, or `.cdidxrc.json`. | @@ -430,8 +430,8 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し | freshness | `--parallelism` による parallel full-scan、`--files` / `--commits` による差分更新、`--watch` による継続更新、`status --check` による完全一致確認、`--stale-after` / `CDIDX_STALE_AFTER` による age threshold 上書きに対応します。 | | storage | `.cdidx/codeindex.db` に保存する local-first 設計。ネストしたディレクトリからの query コマンドは、current directory にフォールバックする前に最上位祖先の `.cdidx/codeindex.db` を優先します。既定の SQLite 保存先は `--data-dir `、`CDIDX_DATA_DIR`、`XDG_DATA_HOME` で workspace 外へ移せます。明示的な `--db ` は引き続き最優先です。 | | DB maintenance | 新規 index DB は SQLite incremental auto-vacuum を使います。成功した writer 実行は WAL を `TRUNCATE` checkpoint します。既存 DB は `cdidx vacuum` で free page を回収でき、legacy no-autovacuum DB は初回だけ full `VACUUM` で変換します。`cdidx db schema` は on-disk schema を出力し、`cdidx db prune --dry-run|--apply` は orphaned DB rows を検査・削除します。`status --json` は `db_pragma_settings` 配下に metrics を出力します。 | -| security defaults | POSIX では `.cdidx` を `0700` 権限で作成します。`status --json` は利用可能な場合に実効 POSIX mode を `data_dir_mode` として報告します。 | -| diagnostics | `status --config` は source attribution 付きの effective configuration を出力し、`status --explain ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書きます。 | +| security defaults | POSIX では `.cdidx` を `0700` 権限で作成し、lifecycle log、metrics log、MCP audit log、query trace log は作成時点から owner read/write のみで作成します。metrics log と audit log は bounded slot へ rotation し、query trace log は bounded な保持件数へ pruning します。`status --json` は利用可能な場合に実効 POSIX mode を `data_dir_mode` として報告します。 | +| diagnostics | `status --config` は source attribution 付きの effective configuration を出力し、`status --explain ` は readiness field の意味と対処を説明します。read 系コマンドは `--profile`、`--slow-query-ms `、--trace=stderr|file|none に対応し、file trace は lifecycle log と同じ場所に日次 `query-trace-YYYYMMDD.jsonl` を書き、最新30件の trace file を保持します。 | | drift checks | `cdidx diff ` は schema、file、symbol、reference の差分を比較します。exit code は `0` identical、`1` drift、`2` schema mismatch、`3` unreadable DB です。 | | extensibility / feedback | `~/.config/cdidx/hooks/*.dll` または `CDIDX_HOOKS_DIR` の post-extraction hook で永続化前のシンボルと参照を拡張できます。`cdidx suggestions` はローカル提案履歴の一覧表示、詳細表示、エクスポートに対応し、MCP 提案の近似重複排除しきい値は CLI、env、`.cdidxrc.json` で調整できます。 | | language coverage | 78 言語を検出し、対応言語ではシンボルとグラフも利用可能です。 | diff --git a/changelog.d/unreleased/2843.security.md b/changelog.d/unreleased/2843.security.md new file mode 100644 index 0000000000..c90c0ee913 --- /dev/null +++ b/changelog.d/unreleased/2843.security.md @@ -0,0 +1,18 @@ +--- +category: security +issues: + - 2843 +affected: + - src/CodeIndex/Cli/PrivateLogFile.cs + - src/CodeIndex/Cli/GlobalToolLog.cs + - tests/CodeIndex.Tests/GlobalToolLogTests.cs + - README.md +--- + +## English + +- **Lifecycle logs are created private from the start on POSIX (#2843)** — new lifecycle log files now request owner-read/write mode at creation time, avoiding the window where command arguments and local paths could be appended before permissions were tightened. + +## 日本語 + +- **POSIX の lifecycle log を作成時点から private にしました (#2843)** — 新しい lifecycle log ファイルは作成時に owner read/write mode を要求するようになり、コマンド引数やローカルパスが書き込まれてから権限を締めるまでの隙間を避けます。 diff --git a/changelog.d/unreleased/2853.security.md b/changelog.d/unreleased/2853.security.md new file mode 100644 index 0000000000..513009750a --- /dev/null +++ b/changelog.d/unreleased/2853.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2853 +affected: + - src/CodeIndex/Cli/MetricsSink.cs + - tests/CodeIndex.Tests/MetricsSinkTests.cs + - README.md +--- + +## English + +- **Metrics JSONL files are private and bounded on POSIX (#2853)** — metrics output now creates new files with owner-read/write permissions and rotates the configured JSONL destination into bounded slots when it reaches the size limit. + +## 日本語 + +- **POSIX の metrics JSONL を private かつ bounded にしました (#2853)** — metrics 出力は新規ファイルを owner read/write 権限で作成し、設定された JSONL 出力先がサイズ上限に達したら bounded slot へ rotation します。 diff --git a/changelog.d/unreleased/2854.security.md b/changelog.d/unreleased/2854.security.md new file mode 100644 index 0000000000..b52eb5fa10 --- /dev/null +++ b/changelog.d/unreleased/2854.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2854 +affected: + - src/CodeIndex/Mcp/AuditLogSink.cs + - tests/CodeIndex.Tests/AuditLogSinkTests.cs + - README.md +--- + +## English + +- **MCP audit logs are created private on POSIX (#2854)** — audit log probe and record writes now create new files with owner-read/write permissions, including files recreated after rotation. + +## 日本語 + +- **POSIX の MCP audit log を private に作成するようにしました (#2854)** — audit log の probe と record 書き込みは、新規ファイルを owner read/write 権限で作成します。rotation 後に再作成されるファイルも同じです。 diff --git a/changelog.d/unreleased/2884.security.md b/changelog.d/unreleased/2884.security.md new file mode 100644 index 0000000000..a6f8625ffb --- /dev/null +++ b/changelog.d/unreleased/2884.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2884 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - README.md +--- + +## English + +- **Query trace files are private and retention-bounded on POSIX (#2884)** — `--trace=file` now creates query trace JSONL files with owner-read/write permissions and prunes trace output to the newest 30 files. + +## 日本語 + +- **POSIX の query trace file を private かつ保持件数 bounded にしました (#2884)** — `--trace=file` は query trace JSONL ファイルを owner read/write 権限で作成し、trace 出力を最新30ファイルに pruning します。 diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index 23d355d9d5..99217b9fff 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -87,10 +87,7 @@ internal static class GlobalToolLog } private static StreamWriter CreateLogWriter(string logPath) => - new(new FileStream(logPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite), new UTF8Encoding(false)) - { - AutoFlush = true, - }; + PrivateLogFile.OpenAppendText(logPath); internal static void Info(string message) => CurrentSession.Value?.Write("INFO", message); @@ -406,15 +403,7 @@ private static void PruneOldLogs(string logDirectory, int retainedLogFileCount) { try { - var oldLogs = new DirectoryInfo(logDirectory) - .EnumerateFiles("stderr-*.log", SearchOption.TopDirectoryOnly) - .OrderByDescending(file => file.LastWriteTimeUtc) - .ThenByDescending(file => file.Name, StringComparer.Ordinal) - .Skip(retainedLogFileCount) - .ToList(); - - foreach (var file in oldLogs) - file.Delete(); + PrivateLogFile.PruneOldFiles(logDirectory, "stderr-*.log", retainedLogFileCount); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -429,8 +418,7 @@ private static void HardenLogFiles(string logDirectory) try { - foreach (var file in new DirectoryInfo(logDirectory).EnumerateFiles("stderr-*.log", SearchOption.TopDirectoryOnly)) - SetLogFilePermissions(file.FullName); + PrivateLogFile.HardenExisting(logDirectory, "stderr-*.log"); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -445,7 +433,7 @@ private static void SetLogFilePermissions(string logPath) try { - File.SetUnixFileMode(logPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); + PrivateLogFile.TrySetPrivatePermissions(logPath); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { diff --git a/src/CodeIndex/Cli/MetricsSink.cs b/src/CodeIndex/Cli/MetricsSink.cs index e0cffe2014..4ca2c9b6b9 100644 --- a/src/CodeIndex/Cli/MetricsSink.cs +++ b/src/CodeIndex/Cli/MetricsSink.cs @@ -20,8 +20,16 @@ internal static class MetricsSink { private static readonly AsyncLocal CurrentSession = new(); internal const string EnvVarName = "CDIDX_METRICS"; + internal const long DefaultMaxBytes = 50L * 1024 * 1024; + internal const int RotationKeep = 3; - internal static IDisposable? TryStart(string? explicitPath) + internal static IDisposable? TryStart(string? explicitPath) => + TryStart(explicitPath, DefaultMaxBytes); + + internal static IDisposable? TryStartForTesting(string? explicitPath, long maxBytes) => + TryStart(explicitPath, maxBytes); + + private static IDisposable? TryStart(string? explicitPath, long maxBytes) { var path = ResolvePath(explicitPath); if (string.IsNullOrWhiteSpace(path)) @@ -34,12 +42,14 @@ internal static class MetricsSink if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); - var stream = new FileStream(fullPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); - var writer = new StreamWriter(stream, new UTF8Encoding(false)) + long bytesWritten; + using (var probe = PrivateLogFile.OpenAppend(fullPath, FileShare.ReadWrite)) { - AutoFlush = true, - }; - var session = new Session(writer, fullPath); + bytesWritten = probe.Length; + } + PrivateLogFile.TrySetPrivatePermissions(fullPath); + + var session = new Session(fullPath, maxBytes, bytesWritten); CurrentSession.Value = session; return session; } @@ -72,13 +82,16 @@ internal static void Record(MetricsEvent evt) internal sealed class Session : IDisposable { private readonly object _gate = new(); - private readonly StreamWriter _writer; + private readonly Encoding _utf8NoBom = new UTF8Encoding(false); + private readonly long _maxBytes; + private long _bytesWritten; private bool _disposed; - public Session(StreamWriter writer, string path) + public Session(string path, long maxBytes, long bytesWritten) { - _writer = writer; Path = path; + _maxBytes = maxBytes; + _bytesWritten = bytesWritten; } public string Path { get; } @@ -92,7 +105,15 @@ public void Write(MetricsEvent evt) try { - _writer.WriteLine(SerializeEvent(evt)); + RotateIfNeededLocked(); + var encoded = _utf8NoBom.GetBytes(SerializeEvent(evt) + Environment.NewLine); + using (var stream = PrivateLogFile.OpenAppend(Path, FileShare.ReadWrite)) + { + stream.Write(encoded, 0, encoded.Length); + stream.Flush(); + } + _bytesWritten += encoded.Length; + RotateIfNeededLocked(); } catch { @@ -110,16 +131,17 @@ public void Dispose() _disposed = true; CurrentSession.Value = null; - try - { - _writer.Dispose(); - } - catch - { - // Best-effort only / ベストエフォートのみ - } } } + + private void RotateIfNeededLocked() + { + if (_bytesWritten < _maxBytes) + return; + + if (PrivateLogFile.TryRotateSlots(Path, RotationKeep)) + _bytesWritten = 0; + } } internal static string SerializeEvent(MetricsEvent evt) diff --git a/src/CodeIndex/Cli/PrivateLogFile.cs b/src/CodeIndex/Cli/PrivateLogFile.cs new file mode 100644 index 0000000000..9dd6d06feb --- /dev/null +++ b/src/CodeIndex/Cli/PrivateLogFile.cs @@ -0,0 +1,130 @@ +using System.Text; +using CodeIndex.Indexer; + +namespace CodeIndex.Cli; + +internal static class PrivateLogFile +{ + internal const UnixFileMode PrivateFileMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + + internal static FileStream OpenAppend(string path, FileShare share = FileShare.ReadWrite) + { + if (OperatingSystem.IsWindows()) + return new FileStream(path, FileMode.Append, FileAccess.Write, share); + + return new FileStream(path, new FileStreamOptions + { + Mode = FileMode.Append, + Access = FileAccess.Write, + Share = share, + UnixCreateMode = PrivateFileMode, + }); + } + + internal static StreamWriter OpenAppendText(string path) + => new(OpenAppend(path), new UTF8Encoding(false)) + { + AutoFlush = true, + }; + + internal static void TrySetPrivatePermissions(string path) + { + if (OperatingSystem.IsWindows()) + return; + + try + { + File.SetUnixFileMode(path, PrivateFileMode); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) + { + // Best-effort only / ベストエフォートのみ + } + } + + internal static void HardenExisting(string directory, string pattern) + { + if (OperatingSystem.IsWindows()) + return; + + try + { + foreach (var file in new DirectoryInfo(directory).EnumerateFiles(pattern, SearchOption.TopDirectoryOnly)) + TrySetPrivatePermissions(file.FullName); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort only / ベストエフォートのみ + } + } + + internal static void PruneOldFiles(string directory, string pattern, int retainedFileCount) + { + try + { + var oldFiles = new DirectoryInfo(directory) + .EnumerateFiles(pattern, SearchOption.TopDirectoryOnly) + .OrderByDescending(file => file.LastWriteTimeUtc) + .ThenByDescending(file => file.Name, StringComparer.Ordinal) + .Skip(retainedFileCount) + .ToList(); + + foreach (var file in oldFiles) + file.Delete(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort only / ベストエフォートのみ + } + } + + internal static bool TryRotateSlots(string path, int retainedFileCount) + { + try + { + SafeDelete(SlotPath(path, retainedFileCount - 1)); + + for (var slot = retainedFileCount - 2; slot >= 1; slot--) + { + var current = LongPath.EnsureWindowsPrefix(SlotPath(path, slot)); + var next = LongPath.EnsureWindowsPrefix(SlotPath(path, slot + 1)); + if (!File.Exists(current)) + continue; + if (File.Exists(next)) + SafeDelete(next); + File.Move(current, next); + } + + var ioPath = LongPath.EnsureWindowsPrefix(path); + if (File.Exists(ioPath)) + { + var first = LongPath.EnsureWindowsPrefix(SlotPath(path, 1)); + if (File.Exists(first)) + SafeDelete(first); + File.Move(ioPath, first); + } + + return true; + } + catch + { + return false; + } + } + + private static string SlotPath(string path, int slot) + => slot <= 0 ? path : path + "." + slot.ToString(System.Globalization.CultureInfo.InvariantCulture); + + private static void SafeDelete(string path) + { + try + { + if (File.Exists(path)) + File.Delete(path); + } + catch + { + // Ignore: rotation is best-effort. + } + } +} diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 6a439d799f..a73f46b85a 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -16,6 +16,7 @@ namespace CodeIndex.Cli; internal static class ProgramRunner { + private const int RetainedQueryTraceFileCount = 30; internal const string QuietEnvironmentVariable = "CDIDX_QUIET"; internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; @@ -1271,8 +1272,16 @@ private static void EmitQueryTrace(string mode, string commandName, string[] sub var directory = GlobalToolLog.ResolveLogDirectoryForStatus(); Directory.CreateDirectory(directory); - var path = Path.Combine(directory, $"query-trace-{TimeProvider.GetUtcNow().UtcDateTime:yyyyMMdd}.jsonl"); - File.AppendAllText(path, payload + Environment.NewLine); + PrivateLogFile.HardenExisting(directory, "query-trace-*.jsonl"); + var path = ResolveQueryTracePath(directory); + var encoded = Encoding.UTF8.GetBytes(payload + Environment.NewLine); + using (var stream = PrivateLogFile.OpenAppend(path, FileShare.ReadWrite)) + { + stream.Write(encoded, 0, encoded.Length); + stream.Flush(); + } + PrivateLogFile.TrySetPrivatePermissions(path); + PrivateLogFile.PruneOldFiles(directory, "query-trace-*.jsonl", RetainedQueryTraceFileCount); } catch { @@ -1280,6 +1289,12 @@ private static void EmitQueryTrace(string mode, string commandName, string[] sub } } + private static string ResolveQueryTracePath(string directory) + { + var date = TimeProvider.GetUtcNow().UtcDateTime.ToString("yyyyMMdd", CultureInfo.InvariantCulture); + return Path.Combine(directory, $"query-trace-{date}.jsonl"); + } + private static string BuildQueryTraceJson(string commandName, string[] subArgs, DateTimeOffset timestamp, double elapsedMs, int exitCode, int? resultCount) { var payload = new JsonObject diff --git a/src/CodeIndex/Mcp/AuditLogSink.cs b/src/CodeIndex/Mcp/AuditLogSink.cs index 898bf2219a..6065ceb497 100644 --- a/src/CodeIndex/Mcp/AuditLogSink.cs +++ b/src/CodeIndex/Mcp/AuditLogSink.cs @@ -4,6 +4,7 @@ using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Nodes; +using CodeIndex.Cli; using CodeIndex.Indexer; namespace CodeIndex.Mcp; @@ -57,10 +58,11 @@ internal AuditLogSink(string path, long maxBytes, bool includeValues) // 構築時に append open を試行する。既存ディレクトリや書き込み不可ファイルなど // 設定不備を、ProgramRunner が「audit 有効で起動」と表示する前に検出する。 // 後で Record() が握り潰すと操作者には audit 有効に見えるがログは空、となる。 - using (var probe = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + using (var probe = PrivateLogFile.OpenAppend(_path, FileShare.ReadWrite)) { _bytesWritten = probe.Length; } + PrivateLogFile.TrySetPrivatePermissions(_path); } /// Path the sink writes to (absolute, post normalisation). @@ -102,7 +104,7 @@ internal void Record(AuditEvent evt) // 1 レコードごとに open/write/close する。外部 `tail -F` の rotation 追従と // rename 中のロック回避のため。ツール呼び出し頻度はループのホットパスではない // ので open のコストは許容範囲。 - using (var stream = new FileStream(_path, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + using (var stream = PrivateLogFile.OpenAppend(_path, FileShare.ReadWrite)) { stream.Write(encoded, 0, encoded.Length); stream.Flush(); diff --git a/tests/CodeIndex.Tests/AuditLogSinkTests.cs b/tests/CodeIndex.Tests/AuditLogSinkTests.cs index 7221dba02b..92b42aa530 100644 --- a/tests/CodeIndex.Tests/AuditLogSinkTests.cs +++ b/tests/CodeIndex.Tests/AuditLogSinkTests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Text.Json; using System.Text.Json.Nodes; +using CodeIndex.Cli; using CodeIndex.Mcp; namespace CodeIndex.Tests; @@ -177,6 +178,81 @@ public void Record_AppendsJsonlLineToConfiguredPath() } } + [Fact] + public void Constructor_OnUnixCreatesPrivateLogFile() + { + if (OperatingSystem.IsWindows()) + return; + + var path = Path.Combine(Path.GetTempPath(), $"cdidx_audit_private_{Guid.NewGuid():N}.jsonl"); + try + { + using var sink = new AuditLogSink(path, AuditLogSink.DefaultMaxBytes, includeValues: false); + + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(path)); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + + [Fact] + public void Record_OnUnixCreatesPrivateLogFileAfterRotation() + { + if (OperatingSystem.IsWindows()) + return; + + var path = Path.Combine(Path.GetTempPath(), $"cdidx_audit_private_rotation_{Guid.NewGuid():N}.jsonl"); + var rotation1 = path + ".1"; + try + { + using var sink = new AuditLogSink(path, AuditLogSink.MinMaxBytes, includeValues: true); + var payloadKey = new string('k', 32); + var bigEvent = new AuditLogSink.AuditEvent( + Timestamp: DateTimeOffset.UtcNow, + Tool: "search", + CallerName: "test", + CallerVersion: "1.0.0", + RequestId: "1", + ArgKeys: new[] { payloadKey }, + ArgLengths: new[] { new KeyValuePair(payloadKey, 5000) }, + ArgValues: JsonNode.Parse($"{{\"{payloadKey}\":\"{new string('x', 5000)}\"}}"), + ResultCount: 0, + ElapsedMs: 1.0, + ErrorCode: 0, + ErrorType: null); + var smallEvent = new AuditLogSink.AuditEvent( + Timestamp: DateTimeOffset.UtcNow, + Tool: "ping", + CallerName: null, + CallerVersion: null, + RequestId: "2", + ArgKeys: Array.Empty(), + ArgLengths: Array.Empty>(), + ArgValues: null, + ResultCount: 0, + ElapsedMs: 0.5, + ErrorCode: 0, + ErrorType: null); + + sink.Record(bigEvent); + sink.Record(smallEvent); + + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(rotation1)); + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(path)); + } + finally + { + foreach (var p in new[] { path, rotation1, path + ".2" }) + { + if (File.Exists(p)) + File.Delete(p); + } + } + } + [Fact] public void Record_RotatesOnceMaxBytesExceeded() { diff --git a/tests/CodeIndex.Tests/GlobalToolLogTests.cs b/tests/CodeIndex.Tests/GlobalToolLogTests.cs index 2bcd21b4e7..198488a7ea 100644 --- a/tests/CodeIndex.Tests/GlobalToolLogTests.cs +++ b/tests/CodeIndex.Tests/GlobalToolLogTests.cs @@ -8,6 +8,29 @@ namespace CodeIndex.Tests; [Collection("SQLite pool sensitive")] public class GlobalToolLogTests { + [Fact] + public void PrivateLogFile_OpenAppend_OnUnixCreatesPrivateFile() + { + if (OperatingSystem.IsWindows()) + return; + + var path = Path.Combine(Path.GetTempPath(), $"cdidx_private_log_{Guid.NewGuid():N}.log"); + try + { + using (var stream = PrivateLogFile.OpenAppend(path)) + { + stream.WriteByte((byte)'x'); + } + + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(path)); + } + finally + { + if (File.Exists(path)) + File.Delete(path); + } + } + [Fact] public void FormatArgs_RedactsSensitiveArgumentsByDefault() { diff --git a/tests/CodeIndex.Tests/MetricsSinkTests.cs b/tests/CodeIndex.Tests/MetricsSinkTests.cs index 4fdb4225ca..c5adb56050 100644 --- a/tests/CodeIndex.Tests/MetricsSinkTests.cs +++ b/tests/CodeIndex.Tests/MetricsSinkTests.cs @@ -100,6 +100,71 @@ public void Run_WithMetricsFlag_AppendsJsonlRecordForEachInvocation() } } + [Fact] + public void Run_WithMetricsFlag_OnUnixCreatesPrivateFile() + { + if (OperatingSystem.IsWindows()) + return; + + var metricsPath = Path.Combine(Path.GetTempPath(), $"cdidx_metrics_private_{Guid.NewGuid():N}.jsonl"); + try + { + var (exitCode, _, _) = CaptureConsole(() => ProgramRunner.Run( + ["--metrics", metricsPath, "definitely-not-a-command"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(metricsPath)); + } + finally + { + if (File.Exists(metricsPath)) + File.Delete(metricsPath); + } + } + + [Fact] + public void Record_RotatesMetricsLogAtMaxBytes() + { + var metricsPath = Path.Combine(Path.GetTempPath(), $"cdidx_metrics_rotate_{Guid.NewGuid():N}.jsonl"); + try + { + using var session = MetricsSink.TryStartForTesting(metricsPath, maxBytes: 1024); + Assert.NotNull(session); + + MetricsSink.Record(new MetricsEvent( + Timestamp: DateTimeOffset.UtcNow, + Tool: "search", + Source: "cli", + ElapsedMs: 1.0, + ExitCode: 1, + Error: new string('x', 2000))); + MetricsSink.Record(new MetricsEvent( + Timestamp: DateTimeOffset.UtcNow, + Tool: "status", + Source: "cli", + ElapsedMs: 1.0, + ExitCode: 0)); + + Assert.True(File.Exists(metricsPath + ".1")); + Assert.True(File.Exists(metricsPath)); + Assert.False(File.Exists(metricsPath + ".3")); + if (!OperatingSystem.IsWindows()) + { + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(metricsPath + ".1")); + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(metricsPath)); + } + } + finally + { + foreach (var path in new[] { metricsPath, metricsPath + ".1", metricsPath + ".2", metricsPath + ".3" }) + { + if (File.Exists(path)) + File.Delete(path); + } + } + } + [Fact] public void Run_WithEnvVarFallback_StillEmitsMetrics() { diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index a3600b0c3f..dd8bb1e014 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -155,6 +155,8 @@ public void Run_QueryTraceFile_AppendsDailyJsonl() Assert.DoesNotContain('{', stderr); var tracePath = Path.Combine(logRoot, $"query-trace-{DateTime.UtcNow:yyyyMMdd}.jsonl"); Assert.True(File.Exists(tracePath)); + if (!OperatingSystem.IsWindows()) + Assert.Equal(PrivateLogFile.PrivateFileMode, File.GetUnixFileMode(tracePath)); var line = File.ReadAllLines(tracePath).Single(); using var document = JsonDocument.Parse(line); Assert.Equal("search", document.RootElement.GetProperty("tool").GetString()); @@ -167,6 +169,50 @@ public void Run_QueryTraceFile_AppendsDailyJsonl() } } + [Fact] + public void Run_QueryTraceFile_PrunesToThirtyTraceFiles() + { + var projectRoot = TestProjectHelper.CreateTempProject("query-trace-prune"); + var logRoot = Path.Combine(Path.GetTempPath(), $"cdidx_query_trace_prune_{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(logRoot); + for (var i = 0; i < 35; i++) + { + var date = new DateTime(2024, 1, 1).AddDays(i); + var path = Path.Combine(logRoot, $"query-trace-{date:yyyyMMdd}.jsonl"); + File.WriteAllText(path, $"old {i}"); + File.SetLastWriteTimeUtc(path, date); + } + + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "public class App { public void Needle() { } }"); + using var env = EnvironmentVariableScope.Capture("CDIDX_GLOBAL_TOOL_LOG_DIR"); + env.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", logRoot); + + var (exitCode, _, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "Needle", "--db", dbPath, "--trace=file"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.DoesNotContain('{', stderr); + + var traces = Directory.GetFiles(logRoot, "query-trace-*.jsonl", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName) + .OrderBy(name => name, StringComparer.Ordinal) + .ToArray(); + Assert.Equal(30, traces.Length); + Assert.DoesNotContain("query-trace-20240101.jsonl", traces); + Assert.Contains($"query-trace-{DateTime.UtcNow:yyyyMMdd}.jsonl", traces); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + if (Directory.Exists(logRoot)) + Directory.Delete(logRoot, recursive: true); + } + } + [Fact] public void TryConsumeSuggestionDedupThresholdFlag_SetsEnvironmentAndRemovesFlag() {