From 4fcee632c35bf9c871e642a16b90d3508f4e3065 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:07:11 +0900 Subject: [PATCH 1/8] Cap did-you-mean input size (#3181) --- changelog.d/unreleased/3181.fixed.md | 16 ++++++++++++++++ src/CodeIndex/Cli/ConsoleUi.cs | 21 +++++++++++++++++---- tests/CodeIndex.Tests/ConsoleUiTests.cs | 18 ++++++++++++++++++ 3 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/3181.fixed.md diff --git a/changelog.d/unreleased/3181.fixed.md b/changelog.d/unreleased/3181.fixed.md new file mode 100644 index 0000000000..a3afa77145 --- /dev/null +++ b/changelog.d/unreleased/3181.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3181 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs +--- + +## English + +- **Did-you-mean suggestions now reject oversized inputs before distance matching (#3181)** — command, flag, language, and kind suggestions skip values beyond the diagnostic display limit so typo matching does not allocate large edit-distance matrices for malformed input. + +## 日本語 + +- **Did-you-mean 候補は距離計算前に過大入力を拒否するようになりました (#3181)** — command / flag / language / kind の候補提示は診断表示上限を超える値をスキップし、不正な入力で巨大な編集距離行列を割り当てないようになりました。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index ff96ea5933..26d5e03bc8 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -128,6 +128,7 @@ public static string FormatSummaryLine(string label, object? value, int labelWid => $"{indent}{label.PadRight(labelWidth)}: {value}"; internal const int DefaultDiagnosticValueCharLimit = 120; + internal const int MaxSuggestionInputCharLength = DefaultDiagnosticValueCharLimit; internal readonly record struct BoundedDisplayText(string Text, bool Truncated, int OriginalLength); @@ -1231,16 +1232,18 @@ private static IReadOnlyList GetCommandUsageNotes(string command) /// public static string? FindClosestMatch(string? input, IEnumerable candidates) { - if (string.IsNullOrWhiteSpace(input)) + var normalized = NormalizeSuggestionInput(input); + if (normalized == null) return null; - var normalized = input.ToLowerInvariant(); string? best = null; var bestDist = int.MaxValue; foreach (var candidate in candidates) { if (string.IsNullOrEmpty(candidate)) continue; + if (candidate.Length > MaxSuggestionInputCharLength) + continue; var candidateNormalized = candidate.ToLowerInvariant(); if (string.Equals(normalized, candidateNormalized, StringComparison.Ordinal)) return candidate; @@ -1265,15 +1268,17 @@ private static IReadOnlyList GetCommandUsageNotes(string command) /// public static IReadOnlyList FindClosestMatches(string? input, IEnumerable candidates, int maxResults = 3) { - if (string.IsNullOrWhiteSpace(input) || maxResults <= 0) + var normalized = NormalizeSuggestionInput(input); + if (normalized == null || maxResults <= 0) return Array.Empty(); - var normalized = input.ToLowerInvariant(); var matches = new List<(string Candidate, int Distance)>(); foreach (var candidate in candidates) { if (string.IsNullOrEmpty(candidate)) continue; + if (candidate.Length > MaxSuggestionInputCharLength) + continue; var candidateNormalized = candidate.ToLowerInvariant(); if (string.Equals(normalized, candidateNormalized, StringComparison.Ordinal)) continue; @@ -1290,6 +1295,14 @@ public static IReadOnlyList FindClosestMatches(string? input, IEnumerabl .ToList(); } + private static string? NormalizeSuggestionInput(string? input) + { + if (input == null || input.Length > MaxSuggestionInputCharLength || string.IsNullOrWhiteSpace(input)) + return null; + + return input.ToLowerInvariant(); + } + private static int GetSuggestionDistanceThreshold(int inputLength, int commandLength) { var shorter = Math.Min(inputLength, commandLength); diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 4f5d213927..c3520572a5 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -1444,6 +1444,14 @@ public void FindClosestMatch_BlankInput_ReturnsNull() Assert.Null(ConsoleUi.FindClosestMatch(" ", new[] { "csharp" })); } + [Fact] + public void FindClosestMatch_OversizedInput_ReturnsNull() + { + var input = new string('x', ConsoleUi.MaxSuggestionInputCharLength + 1); + + Assert.Null(ConsoleUi.FindClosestMatch(input, new[] { "search" })); + } + [Fact] public void FindClosestMatches_ReturnsRankedSuggestions() { @@ -1465,6 +1473,16 @@ public void FindClosestMatches_NoMatchesWithinThreshold_ReturnsEmpty() Assert.Empty(matches); } + [Fact] + public void FindClosestMatches_OversizedInput_ReturnsEmpty() + { + var input = new string('x', ConsoleUi.MaxSuggestionInputCharLength + 1); + + var matches = ConsoleUi.FindClosestMatches(input, new[] { "added", "changed" }); + + Assert.Empty(matches); + } + [Fact] public void FindClosestMatches_ZeroMaxResults_ReturnsEmpty() { From 9cfa25ec50514a1780cda23879d7bb1fc58a1eb1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:27:42 +0900 Subject: [PATCH 2/8] Bound CLI option diagnostics (#3092) --- changelog.d/unreleased/3092.fixed.md | 25 ++++++ src/CodeIndex/Cli/ConsoleUi.cs | 27 +++++- src/CodeIndex/Cli/HookCommandRunner.cs | 7 +- src/CodeIndex/Cli/IndexCommandRunner.Parse.cs | 30 ++++--- src/CodeIndex/Cli/QueryCommandRunner.cs | 84 ++++++++++--------- tests/CodeIndex.Tests/ConsoleUiTests.cs | 19 +++++ .../CodeIndex.Tests/HookCommandRunnerTests.cs | 13 +++ .../IndexCommandRunnerTests.cs | 38 +++++++++ .../QueryCommandRunnerGraphTests.cs | 15 ++++ .../QueryCommandRunnerMapTests.cs | 31 +++++++ .../QueryCommandRunnerSearchTests.cs | 64 ++++++++++++++ .../QueryCommandRunnerTests.cs | 55 ++++++++++++ 12 files changed, 351 insertions(+), 57 deletions(-) create mode 100644 changelog.d/unreleased/3092.fixed.md diff --git a/changelog.d/unreleased/3092.fixed.md b/changelog.d/unreleased/3092.fixed.md new file mode 100644 index 0000000000..1f01700972 --- /dev/null +++ b/changelog.d/unreleased/3092.fixed.md @@ -0,0 +1,25 @@ +--- +category: fixed +issues: + - 3092 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/IndexCommandRunner.Parse.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/HookCommandRunner.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - tests/CodeIndex.Tests/HookCommandRunnerTests.cs +--- + +## English + +- **CLI option diagnostics now bound user-supplied values (#3092)** — index, query, and hooks warnings/errors now use the shared bounded display formatter before echoing option tokens or values, truncating oversized input and flattening control characters. + +## 日本語 + +- **CLI option 診断はユーザー指定値を上限付き表示にするようになりました (#3092)** — index / query / hooks の warning / error は option token や値を表示する前に共有の bounded display formatter を通し、過大入力を切り詰めて制御文字を空白化します。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 26d5e03bc8..9ea82aebce 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -140,19 +140,40 @@ internal static BoundedDisplayText BoundDisplayText(string? value, int maxChars if (value == null) return new BoundedDisplayText("", Truncated: false, OriginalLength: 0); - if (value.Length <= maxChars) - return new BoundedDisplayText(value, Truncated: false, value.Length); + var displayValue = FlattenDiagnosticControlChars(value); + if (displayValue.Length <= maxChars) + return new BoundedDisplayText(displayValue, Truncated: false, value.Length); var marker = string.Create(CultureInfo.InvariantCulture, $"... "); var text = maxChars == 0 ? marker.TrimStart('.', ' ') - : value[..maxChars] + marker; + : displayValue[..maxChars] + marker; return new BoundedDisplayText(text, Truncated: true, value.Length); } internal static string FormatBoundedValue(string? value, int maxChars = DefaultDiagnosticValueCharLimit) => BoundDisplayText(value, maxChars).Text; + private static string FlattenDiagnosticControlChars(string value) + { + for (var i = 0; i < value.Length; i++) + { + if (char.IsControl(value[i])) + { + var chars = value.ToCharArray(); + for (var j = i; j < chars.Length; j++) + { + if (char.IsControl(chars[j])) + chars[j] = ' '; + } + + return new string(chars); + } + } + + return value; + } + private const int SpinnerFrameDelayMs = 100; private const int SpinnerStopDelayMs = 20; private const int ConsoleLineMargin = 1; diff --git a/src/CodeIndex/Cli/HookCommandRunner.cs b/src/CodeIndex/Cli/HookCommandRunner.cs index 7cd25a5217..75657baa43 100644 --- a/src/CodeIndex/Cli/HookCommandRunner.cs +++ b/src/CodeIndex/Cli/HookCommandRunner.cs @@ -65,7 +65,10 @@ internal static HookCommandOptions ParseArgs(string[] args) break; default: if (args[i].StartsWith("-", StringComparison.Ordinal)) - Console.Error.WriteLine($"Warning: unknown option '{args[i]}' (ignored) / 不明なオプション '{args[i]}'(無視されます)"); + { + var displayValue = ConsoleUi.FormatBoundedValue(args[i]); + Console.Error.WriteLine($"Warning: unknown option '{displayValue}' (ignored) / 不明なオプション '{displayValue}'(無視されます)"); + } else if (command == null) command = args[i]; else @@ -137,7 +140,7 @@ private static int UnknownCommand(HookCommandOptions options, JsonSerializerOpti { if (!options.Json) PrintUsage(); - return WriteResult(options.Json, jsonOptions, "error", $"unknown hooks command: {options.Command}", projectPath, null, null, CommandExitCodes.UsageError); + return WriteResult(options.Json, jsonOptions, "error", $"unknown hooks command: {ConsoleUi.FormatBoundedValue(options.Command)}", projectPath, null, null, CommandExitCodes.UsageError); } private static bool IsManagedHook(string content) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs index 1e7e35da5f..059f2fd0fa 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs @@ -136,7 +136,8 @@ public static IndexCommandOptions ParseArgs(string[] args) } else { - Console.Error.WriteLine($"Warning: invalid --debounce value '{args[i + 1]}' (ignored; must be a non-negative integer in milliseconds) / 不正な --debounce 値 '{args[i + 1]}'(無視。ミリ秒の0以上の整数を指定)"); + var displayValue = ConsoleUi.FormatBoundedValue(args[i + 1]); + Console.Error.WriteLine($"Warning: invalid --debounce value '{displayValue}' (ignored; must be a non-negative integer in milliseconds) / 不正な --debounce 値 '{displayValue}'(無視。ミリ秒の0以上の整数を指定)"); i++; } break; @@ -338,7 +339,7 @@ private static FileIndexer.SymlinkPolicy ParseSymlinkPolicy(string value, FileIn case "all": return FileIndexer.SymlinkPolicy.All; default: - parseError ??= $"invalid --follow-symlinks value '{value}': expected none, internal, or all"; + parseError ??= $"invalid --follow-symlinks value '{ConsoleUi.FormatBoundedValue(value)}': expected none, internal, or all"; return fallback; } } @@ -347,9 +348,10 @@ private static string BuildUnknownIndexOptionError(string token) { var name = TrimInlineValue(token); var suggestion = ConsoleUi.FindClosestMatch(name, AcceptedIndexFlags); + var displayToken = ConsoleUi.FormatBoundedValue(token); return suggestion == null - ? $"unknown option '{token}'" - : $"unknown option '{token}'\nDid you mean: {suggestion}?"; + ? $"unknown option '{displayToken}'" + : $"unknown option '{displayToken}'\nDid you mean: {suggestion}?"; } private static string TrimInlineValue(string token) @@ -452,11 +454,13 @@ private static int ParseIndexParallelism(string value, int fallback, string sour if (parsed <= MaxIndexParallelism) return parsed; - Console.Error.WriteLine($"Warning: {source} value '{value}' exceeds the maximum {MaxIndexParallelism}; using {MaxIndexParallelism} / {source} 値 '{value}' は最大 {MaxIndexParallelism} を超えています。{MaxIndexParallelism} を使用します"); + var displayValue = ConsoleUi.FormatBoundedValue(value); + Console.Error.WriteLine($"Warning: {source} value '{displayValue}' exceeds the maximum {MaxIndexParallelism}; using {MaxIndexParallelism} / {source} 値 '{displayValue}' は最大 {MaxIndexParallelism} を超えています。{MaxIndexParallelism} を使用します"); return MaxIndexParallelism; } - Console.Error.WriteLine($"Warning: invalid {source} value '{value}' (ignored; use a positive integer) / 不正な {source} 値 '{value}'(無視。正の整数を指定)"); + var invalidDisplayValue = ConsoleUi.FormatBoundedValue(value); + Console.Error.WriteLine($"Warning: invalid {source} value '{invalidDisplayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{invalidDisplayValue}'(無視。正の整数を指定)"); return fallback; } @@ -469,7 +473,8 @@ private static int ParseIndexParallelism(string value, int fallback, string sour if (FileIndexer.TryParseMaxFileSizeBytes(value, out var parsed)) return parsed; - Console.Error.WriteLine($"Warning: invalid {FileIndexer.MaxFileSizeEnvironmentVariable} value '{value}' (ignored; use positive bytes or K/M/G suffixes) / 不正な {FileIndexer.MaxFileSizeEnvironmentVariable} 値 '{value}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); + var displayValue = ConsoleUi.FormatBoundedValue(value); + Console.Error.WriteLine($"Warning: invalid {FileIndexer.MaxFileSizeEnvironmentVariable} value '{displayValue}' (ignored; use positive bytes or K/M/G suffixes) / 不正な {FileIndexer.MaxFileSizeEnvironmentVariable} 値 '{displayValue}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); return null; } @@ -478,7 +483,8 @@ private static int ParseIndexParallelism(string value, int fallback, string sour if (FileIndexer.TryParseMaxFileSizeBytes(value, out var parsed)) return parsed; - Console.Error.WriteLine($"Warning: invalid --max-file-bytes value '{value}' (ignored; use positive bytes or K/M/G suffixes) / 不正な --max-file-bytes 値 '{value}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); + var displayValue = ConsoleUi.FormatBoundedValue(value); + Console.Error.WriteLine($"Warning: invalid --max-file-bytes value '{displayValue}' (ignored; use positive bytes or K/M/G suffixes) / 不正な --max-file-bytes 値 '{displayValue}'(無視。正の byte 数または K/M/G 接尾辞を指定)"); return fallback; } @@ -493,7 +499,8 @@ private static int ParseMaxSymbolsPerFile(string value, int fallback, string sou return fallback; } - Console.Error.WriteLine($"Warning: invalid {source} value '{value}' (ignored; use a positive integer) / 不正な {source} 値 '{value}'(無視。正の整数を指定)"); + var displayValue = ConsoleUi.FormatBoundedValue(value); + Console.Error.WriteLine($"Warning: invalid {source} value '{displayValue}' (ignored; use a positive integer) / 不正な {source} 値 '{displayValue}'(無視。正の整数を指定)"); return fallback; } @@ -510,7 +517,8 @@ private static DurationOutputFormat ParseDurationFormat(string value, DurationOu private static DurationOutputFormat WarnInvalidDurationFormat(string value, DurationOutputFormat fallback) { - Console.Error.WriteLine($"Warning: invalid --duration-format value '{value}' (ignored; use auto, seconds, or hms) / 不正な --duration-format 値 '{value}'(無視。auto, seconds, hms のいずれかを指定)"); + var displayValue = ConsoleUi.FormatBoundedValue(value); + Console.Error.WriteLine($"Warning: invalid --duration-format value '{displayValue}' (ignored; use auto, seconds, or hms) / 不正な --duration-format 値 '{displayValue}'(無視。auto, seconds, hms のいずれかを指定)"); return fallback; } @@ -539,7 +547,7 @@ private static CompletionNotificationMode ParseCompletionNotificationMode(string private static CompletionNotificationMode WarnInvalidCompletionNotificationMode(string value, CompletionNotificationMode fallback, ref string? parseError) { - parseError ??= $"invalid --notify value '{value}': expected auto, bell, osc9, desktop, or none"; + parseError ??= $"invalid --notify value '{ConsoleUi.FormatBoundedValue(value)}': expected auto, bell, osc9, desktop, or none"; return fallback; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 93693451fd..df31eda624 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -293,7 +293,7 @@ public static int RunBatch(string[] cmdArgs, JsonSerializerOptions jsonOptions) continue; } - Console.Error.WriteLine($"Error: {arg} is not supported for batch."); + Console.Error.WriteLine($"Error: {ConsoleUi.FormatBoundedValue(arg)} is not supported for batch."); Console.Error.WriteLine($"Usage: {ConsoleUi.GetUsageLine("batch")}"); return CommandExitCodes.UsageError; } @@ -2882,32 +2882,32 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) i++; } if ((arg == "--limit" || arg == "--top") && (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var limit) || limit <= 0)) - return BuildPositiveIntegerError("--limit", value, arg); + return BuildPositiveIntegerError("--limit", ConsoleUi.FormatBoundedValue(value), arg); if ((arg == "--limit" || arg == "--top") && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var limitCeil) && NumericFlagUpperBounds.TryGetValue("--limit", out var limitMax) && limitCeil > limitMax) - return BuildPositiveIntegerUpperBoundError("--limit", value, limitMax); + return BuildPositiveIntegerUpperBoundError("--limit", ConsoleUi.FormatBoundedValue(value), limitMax); if (arg == "--max-line-width" && (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var widthValue) || widthValue < 0)) - return BuildNonNegativeIntegerError(arg, value); + return BuildNonNegativeIntegerError(arg, ConsoleUi.FormatBoundedValue(value)); if (arg == "--max-line-width" && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var widthCeil) && widthCeil > LineWidthFormatter.MaxAllowedLineWidth) - return BuildNonNegativeIntegerUpperBoundError("--max-line-width", value, LineWidthFormatter.MaxAllowedLineWidth); + return BuildNonNegativeIntegerUpperBoundError("--max-line-width", ConsoleUi.FormatBoundedValue(value), LineWidthFormatter.MaxAllowedLineWidth); if ((arg == "--before" || arg == "--after") && (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var context) || context < 0)) - return BuildNonNegativeIntegerError(arg, value); + return BuildNonNegativeIntegerError(arg, ConsoleUi.FormatBoundedValue(value)); if ((arg == "--before" || arg == "--after") && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var contextCeil) && NumericFlagUpperBounds.TryGetValue(arg, out var contextMax) && contextCeil > contextMax) - return BuildNonNegativeIntegerUpperBoundError(arg, value, contextMax); + return BuildNonNegativeIntegerUpperBoundError(arg, ConsoleUi.FormatBoundedValue(value), contextMax); if (arg == "--snippet-lines" && (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var snippetLines) || snippetLines <= 0)) - return BuildPositiveIntegerError(arg, value, arg); + return BuildPositiveIntegerError(arg, ConsoleUi.FormatBoundedValue(value), arg); if (arg == "--snippet-lines" && int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var snippetLinesCeil) && NumericFlagUpperBounds.TryGetValue(arg, out var snippetLinesMax) && snippetLinesCeil > snippetLinesMax) - return BuildPositiveIntegerUpperBoundError(arg, value, snippetLinesMax); + return BuildPositiveIntegerUpperBoundError(arg, ConsoleUi.FormatBoundedValue(value), snippetLinesMax); if ((arg == "--focus-line" || arg == "--focus-column") && (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var focus) || focus <= 0)) - return BuildPositiveIntegerError(arg, value, arg); + return BuildPositiveIntegerError(arg, ConsoleUi.FormatBoundedValue(value), arg); if (arg == "--query") { queryCount++; @@ -2922,7 +2922,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (rawArg.StartsWith('-')) { - var error = $"Error: unsupported option for find: {rawArg}"; + var error = $"Error: unsupported option for find: {ConsoleUi.FormatBoundedValue(rawArg)}"; // Suggest the closest accepted find flag for typos like `--paht` → `--path` // (#1582). Strip any inline `=value` portion before matching, since the prefix // might not have been a recognized value-taking option (TrySplitInlineOptionValue @@ -4579,7 +4579,7 @@ private static bool TryNormalizeDepsFormat(string rawFormat, out string format, case OutputFormatJsonGraph: return true; default: - error = $"Error: deps --format must be one of edgelist, dot, graphml, or json-graph; got '{rawFormat}'."; + error = $"Error: deps --format must be one of edgelist, dot, graphml, or json-graph; got '{ConsoleUi.FormatBoundedValue(rawFormat)}'."; return false; } } @@ -5990,7 +5990,7 @@ void AddStatusCheckScopes(string rawScopes) statusCheckScopes.Add(scope); break; default: - AddParseError($"Error: unsupported --check scope '{rawScope}'. Use one or more of workspace, fold, graph, issues, hotspot, csharp, sql, newer."); + AddParseError($"Error: unsupported --check scope '{ConsoleUi.FormatBoundedValue(rawScope)}'. Use one or more of workspace, fold, graph, issues, hotspot, csharp, sql, newer."); break; } } @@ -6010,7 +6010,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) { if (seenSingleValueOptions.Add(canonicalName)) return; - Console.Error.WriteLine($"Warning: {canonicalName} specified more than once; the rightmost CLI value '{newValue}' takes precedence over earlier CLI values and any environment/config default."); + var displayValue = ConsoleUi.FormatBoundedValue(newValue); + Console.Error.WriteLine($"Warning: {canonicalName} specified more than once; the rightmost CLI value '{displayValue}' takes precedence over earlier CLI values and any environment/config default."); } for (int i = 0; i < args.Length; i++) @@ -6100,7 +6101,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) } else { - AddParseError($"Error: --json format must be one of ndjson or array, got '{inlineValue}'. Hint: use `--json` or `--json=ndjson` for newline-delimited JSON, or `--json=array` for a single JSON array."); + AddParseError($"Error: --json format must be one of ndjson or array, got '{ConsoleUi.FormatBoundedValue(inlineValue)}'. Hint: use `--json` or `--json=ndjson` for newline-delimited JSON, or `--json=array` for a single JSON array."); } break; case "--indexed-only": @@ -6117,7 +6118,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) } else { - AddParseError($"Error: unsupported --capability value '{capabilityValue}'. Use graph, symbols, or references."); + AddParseError($"Error: unsupported --capability value '{ConsoleUi.FormatBoundedValue(capabilityValue)}'. Use graph, symbols, or references."); } break; case "--format": @@ -6142,7 +6143,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) var allowedFormats = allowIssueDraftsFormat ? "text, json, count, compact, csv, tsv, lsp, qf, sarif, or issue-drafts" : "text, json, count, compact, csv, tsv, lsp, qf, or sarif"; - AddParseError($"Error: --format must be one of {allowedFormats}; got '{formatValue}'."); + AddParseError($"Error: --format must be one of {allowedFormats}; got '{ConsoleUi.FormatBoundedValue(formatValue)}'."); } } else @@ -6454,7 +6455,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) minEntrypointConfidence = parsedMinEntrypointConfidence; } else - AddParseError($"Error: --min-entrypoint-confidence must be a number from 0.0 through 1.0; got '{minEntrypointConfidenceValue}'."); + AddParseError($"Error: --min-entrypoint-confidence must be a number from 0.0 through 1.0; got '{ConsoleUi.FormatBoundedValue(minEntrypointConfidenceValue)}'."); break; case "--check": if (allowStatusCheck) @@ -6540,7 +6541,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) } else { - AddParseError($"Error: unsupported option: {currentArg}. Use `--` before a query literal that starts with `-`."); + AddParseError($"Error: unsupported option: {ConsoleUi.FormatBoundedValue(currentArg)}. Use `--` before a query literal that starts with `-`."); } break; case "--path": @@ -6588,7 +6589,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) since = parsedSince; } else - AddParseError($"Error: could not parse --since value '{sinceValue}' as a date/time. Use ISO 8601 format (e.g. 2024-01-01 or 2024-01-01T00:00:00Z)."); + AddParseError($"Error: could not parse --since value '{ConsoleUi.FormatBoundedValue(sinceValue)}' as a date/time. Use ISO 8601 format (e.g. 2024-01-01 or 2024-01-01T00:00:00Z)."); break; case "--start": case "--start-line": @@ -6701,7 +6702,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) } else { - AddParseError($"Error: invalid --snippet-focus value '{snippetFocusValue}'. Use leftmost, quality, or proximity."); + AddParseError($"Error: invalid --snippet-focus value '{ConsoleUi.FormatBoundedValue(snippetFocusValue)}'. Use leftmost, quality, or proximity."); } break; case "--max-line-width": @@ -6719,7 +6720,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) default: if (args[i].StartsWith('-')) { - AddParseError($"Error: unsupported option: {args[i]}. Use `--` before a query literal that starts with `-`."); + AddParseError($"Error: unsupported option: {ConsoleUi.FormatBoundedValue(args[i])}. Use `--` before a query literal that starts with `-`."); break; } else if (query == null) @@ -6873,7 +6874,7 @@ private static List ParseMapSections(string rawValue, Action add sections.Add(section); break; default: - addParseError($"Error: --sections contains unsupported section '{rawSection}'. Use one or more of tree, languages, hotspots, metrics."); + addParseError($"Error: --sections contains unsupported section '{ConsoleUi.FormatBoundedValue(rawSection)}'. Use one or more of tree, languages, hotspots, metrics."); break; } } @@ -6941,7 +6942,7 @@ private static List ParseMapSections(string rawValue, Action add canonical = "callees"; break; default: - addParseError($"Error: unsupported --fields value '{rawField}'. Use one or more of all, file, workspace, graph, definitions, body, nearby_symbols, references, callers, callees."); + addParseError($"Error: unsupported --fields value '{ConsoleUi.FormatBoundedValue(rawField)}'. Use one or more of all, file, workspace, graph, definitions, body, nearby_symbols, references, callers, callees."); continue; } @@ -7066,7 +7067,7 @@ private static void ValidatePathGlobPattern(string optionName, string pattern, A { if (TryFindUnsupportedBracketGlob(pattern, out var reason)) { - addParseError($"Error: {optionName} '{pattern}' is not a valid glob: {reason}. Hint: escape '[' or ']' with a backslash when matching literal path characters, or use only '*' and '?' wildcards."); + addParseError($"Error: {optionName} '{ConsoleUi.FormatBoundedValue(pattern)}' is not a valid glob: {reason}. Hint: escape '[' or ']' with a backslash when matching literal path characters, or use only '*' and '?' wildcards."); } } @@ -7174,7 +7175,7 @@ private static bool TryResolveHotspotsGroupBy(string? requestedGroupBy, string? groupBy = HotspotsGroupedByNameKind; return true; default: - error = $"Error: unsupported hotspots --group-by value '{requestedGroupBy}'. Use symbol, file, or statement."; + error = $"Error: unsupported hotspots --group-by value '{ConsoleUi.FormatBoundedValue(requestedGroupBy)}'. Use symbol, file, or statement."; return false; } } @@ -7237,7 +7238,7 @@ internal static bool TryParseStaleAfter(string value, out TimeSpan staleAfter, o unit = TimeSpan.FromDays(1); break; default: - error = $"Error: could not parse stale-after value '{value}'. Use a positive duration with m, h, or d suffix (e.g. 30m, 2h, 7d)."; + error = $"Error: could not parse stale-after value '{ConsoleUi.FormatBoundedValue(value)}'. Use a positive duration with m, h, or d suffix (e.g. 30m, 2h, 7d)."; return false; } @@ -7245,20 +7246,20 @@ internal static bool TryParseStaleAfter(string value, out TimeSpan staleAfter, o !double.IsFinite(number) || number <= 0) { - error = $"Error: could not parse stale-after value '{value}'. Use a positive duration with m, h, or d suffix (e.g. 30m, 2h, 7d)."; + error = $"Error: could not parse stale-after value '{ConsoleUi.FormatBoundedValue(value)}'. Use a positive duration with m, h, or d suffix (e.g. 30m, 2h, 7d)."; return false; } var ticks = number * unit.Ticks; if (ticks > TimeSpan.MaxValue.Ticks) { - error = $"Error: stale-after value '{value}' is too large."; + error = $"Error: stale-after value '{ConsoleUi.FormatBoundedValue(value)}' is too large."; return false; } if (ticks > MaxStaleAfter.Ticks) { - error = $"Error: stale-after value '{value}' exceeds the maximum {MaxStaleAfterDisplay}."; + error = $"Error: stale-after value '{ConsoleUi.FormatBoundedValue(value)}' exceeds the maximum {MaxStaleAfterDisplay}."; return false; } @@ -7777,7 +7778,7 @@ private static void AddVisibilityFilterValues(string optionName, string rawValue { if (!KnownVisibilityFilters.Contains(value)) { - addParseError($"Error: unsupported {optionName} value '{value}'. Use one or more of public, protected, internal, private."); + addParseError($"Error: unsupported {optionName} value '{ConsoleUi.FormatBoundedValue(value)}'. Use one or more of public, protected, internal, private."); continue; } @@ -7793,7 +7794,7 @@ private static bool TryWriteInvalidKindFilterError(QueryCommandOptions options, && !alternateAcceptedKinds.Any(kinds => kinds.Contains(options.Kind))) { CommandErrorWriter.Write( - $"invalid --kind value `{options.Kind}`.", + $"invalid --kind value `{ConsoleUi.FormatBoundedValue(options.Kind)}`.", $"use one of: {string.Join(", ", acceptedKinds)}.", GetUsageLineOrThrow(commandName)); return true; @@ -7813,7 +7814,7 @@ private static bool TryWriteInvalidUnusedFilterError(QueryCommandOptions options if (options.UnusedBucket != null && !IsKnownUnusedBucket(options.UnusedBucket)) { CommandErrorWriter.Write( - $"invalid --bucket value `{options.UnusedBucket}`.", + $"invalid --bucket value `{ConsoleUi.FormatBoundedValue(options.UnusedBucket)}`.", $"use one of: {string.Join(", ", OrderedUnusedBuckets)}.", GetUsageLineOrThrow("unused")); return true; @@ -7822,7 +7823,7 @@ private static bool TryWriteInvalidUnusedFilterError(QueryCommandOptions options if (options.MinUnusedConfidence != null && !IsKnownUnusedConfidence(options.MinUnusedConfidence)) { CommandErrorWriter.Write( - $"invalid --min-confidence value `{options.MinUnusedConfidence}`.", + $"invalid --min-confidence value `{ConsoleUi.FormatBoundedValue(options.MinUnusedConfidence)}`.", "use one of: medium, low.", GetUsageLineOrThrow("unused")); return true; @@ -7932,11 +7933,12 @@ private static bool TryWriteUnsupportedOptionError(string commandName, string[] if (eq > 0) nameForSuggestion = nameForSuggestion[..eq]; var suggestion = ConsoleUi.FindClosestMatch(nameForSuggestion, supported.Where(o => o != "--")); + var displayArg = ConsoleUi.FormatBoundedValue(arg); var hint = suggestion == null - ? $"remove `{arg}` and rerun, or use only the options shown in `{commandName} --help`." - : $"Did you mean: {suggestion}? Remove `{arg}` and rerun, or use `{suggestion}` if that is what you meant."; + ? $"remove `{displayArg}` and rerun, or use only the options shown in `{commandName} --help`." + : $"Did you mean: {suggestion}? Remove `{displayArg}` and rerun, or use `{suggestion}` if that is what you meant."; CommandErrorWriter.Write( - $"{arg} is not supported for {commandName}.", + $"{displayArg} is not supported for {commandName}.", hint, GetUsageLineOrThrow(commandName)); return true; @@ -8952,11 +8954,11 @@ private static void WriteGraphReferenceKindHint(string command, string? kind, bo if (AllValidKinds.Contains(kind)) { - Console.Error.WriteLine($"WARN: '{kind}' is a symbol kind, but --kind on '{command}' filters by reference kind ({string.Join(", ", acceptedKinds)}). Use symbols/definition/hotspots/unused to filter by symbol kind."); + Console.Error.WriteLine($"WARN: '{ConsoleUi.FormatBoundedValue(kind)}' is a symbol kind, but --kind on '{command}' filters by reference kind ({string.Join(", ", acceptedKinds)}). Use symbols/definition/hotspots/unused to filter by symbol kind."); return; } - Console.Error.WriteLine($"Hint: '{kind}' is not a known reference kind for '{command}'. Available reference kinds: {string.Join(", ", acceptedKinds)}"); + Console.Error.WriteLine($"Hint: '{ConsoleUi.FormatBoundedValue(kind)}' is not a known reference kind for '{command}'. Available reference kinds: {string.Join(", ", acceptedKinds)}"); var suggestion = ConsoleUi.FindClosestMatch(kind, acceptedKinds); if (suggestion != null) Console.Error.WriteLine($"Did you mean: --kind {suggestion}?"); @@ -9452,7 +9454,7 @@ private static bool TryParsePositiveInt(string rawValue, string optionName, out if (string.Equals(optionName, "--max-line-width", StringComparison.Ordinal)) return TryParseNonNegativeInt(rawValue, optionName, out value, out error, displayRawValue); - displayRawValue ??= rawValue; + displayRawValue ??= ConsoleUi.FormatBoundedValue(rawValue); if (!int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) || value <= 0) { value = 0; @@ -9473,7 +9475,7 @@ private static bool TryParsePositiveInt(string rawValue, string optionName, out private static bool TryParseNonNegativeInt(string rawValue, string optionName, out int value, out string? error, string? displayRawValue = null) { - displayRawValue ??= rawValue; + displayRawValue ??= ConsoleUi.FormatBoundedValue(rawValue); if (!int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) || value < 0) { value = 0; diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index c3520572a5..3615332ed7 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -1483,6 +1483,25 @@ public void FindClosestMatches_OversizedInput_ReturnsEmpty() Assert.Empty(matches); } + [Fact] + public void FormatBoundedValue_FlattensControlCharacters() + { + var formatted = ConsoleUi.FormatBoundedValue("bad\r\nline\t\u001b[31m"); + + Assert.Equal("bad line [31m", formatted); + } + + [Fact] + public void FormatBoundedValue_TruncatedValueFlattensControlCharacters() + { + var value = new string('a', ConsoleUi.DefaultDiagnosticValueCharLimit - 1) + "\nzzz"; + + var formatted = ConsoleUi.FormatBoundedValue(value); + + Assert.DoesNotContain("\n", formatted); + Assert.Contains(" QueryCommandRunner.RunDeps( + ["--format", value], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("deps --format must be one of", stderr); + Assert.Contains("bad forged value", stderr); + Assert.DoesNotContain(value, stderr); + } + [Fact] public void RunReferences_AllowsExcludePathValueThatLooksLikePreviewOption() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs index e274b1ceea..0d37ec1736 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs @@ -40,6 +40,37 @@ public void RunMap_ParseCompact_ImpliesJsonAndPreservesExplicitLimit_Issue3009() Assert.Null(options.ParseError); } + [Fact] + public void RunMap_ParseInvalidMinEntrypointConfidence_TruncatesOversizedValue() + { + var value = new string('x', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); + + var options = QueryCommandRunner.ParseArgs( + ["--min-entrypoint-confidence", value], + jsonDefault: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + + Assert.Contains("--min-entrypoint-confidence must be a number", options.ParseError); + Assert.Contains(" QueryCommandRunner.RunSearch( + ["foo", "--snippet-focus", value], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("invalid --snippet-focus value", stderr); + Assert.Contains(" QueryCommandRunner.RunSearch( + ["foo", "--limit", value], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--limit requires an integer", stderr); + Assert.Contains(" QueryCommandRunner.RunSearch( + ["foo", "--json=" + value], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--json format must be one of ndjson or array", stderr); + Assert.Contains(" QueryCommandRunner.RunSearch( + ["foo", "--format", value], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--format must be one of", stderr); + Assert.Contains(" QueryCommandRunner.RunBatch( + [token], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("is not supported for batch", stderr); + Assert.Contains(" QueryCommandRunner.RunBatch( + [token], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("--bad forged value is not supported for batch", stderr); + Assert.DoesNotContain(token, stderr); + } + [Fact] public void WithDb_SqliteCantOpenSurfacesAccessOpenCategory_Issue2072() { From c6aff08820b992f0cbb5b6e35b1c68e42e12ff31 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:34:59 +0900 Subject: [PATCH 3/8] Bound database path diagnostics (#3093) --- changelog.d/unreleased/3093.security.md | 16 ++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 24 ++++++-- .../QueryCommandRunnerTests.cs | 57 +++++++++++++++++++ 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/3093.security.md diff --git a/changelog.d/unreleased/3093.security.md b/changelog.d/unreleased/3093.security.md new file mode 100644 index 0000000000..bbfd528c76 --- /dev/null +++ b/changelog.d/unreleased/3093.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3093 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +--- + +## English + +- **Database path diagnostics now truncate oversized `--db` values (#3093)** — query and batch database errors keep the URI/path kind visible while bounding echoed database paths before writing stderr. + +## 日本語 + +- **database path 診断は過大な `--db` 値を切り詰めるようになりました (#3093)** — query / batch の database error は URI/path の種別を残しつつ、stderr に出す database path を上限付き表示にしました。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index df31eda624..a3daf22939 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -301,7 +301,7 @@ public static int RunBatch(string[] cmdArgs, JsonSerializerOptions jsonOptions) var isUri = dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase); if (!isUri && !File.Exists(dbPath)) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {Path.GetFullPath(dbPath)}"); + Console.Error.WriteLine($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {FormatDbDiagnosticValue(Path.GetFullPath(dbPath))}"); Console.Error.WriteLine("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun this command."); return CommandExitCodes.DatabaseError; } @@ -7373,7 +7373,7 @@ private static int WithDb(QueryCommandOptions options, JsonSerializerOptions jso { if (!DbPathResolver.TryNormalizeDbPath(dbPath, out fileExistsPath, out var parseError)) { - var boundedDbPath = SqliteFileUri.TruncateDiagnosticValue(dbPath); + var boundedDbPath = FormatDbDiagnosticValue(dbPath); Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: invalid --db file URI: {SqliteFileUri.FormatParseError(parseError)}"); Console.Error.WriteLine($"Hint: pass a valid SQLite file URI such as `file:///absolute/path/to/codeindex.db?immutable=1`; the --db value resolved to: {boundedDbPath}"); GlobalToolLog.Error($"invalid_db_file_uri db={FormatLogValue(dbPath)} exception={FormatLogValue(parseError?.ToString() ?? "")}"); @@ -7385,9 +7385,10 @@ private static int WithDb(QueryCommandOptions options, JsonSerializerOptions jso && !File.Exists(LongPath.EnsureWindowsPrefix(fileExistsPath))) { var resolvedPath = Path.GetFullPath(fileExistsPath); - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {resolvedPath}"); + var displayPath = FormatDbDiagnosticValue(resolvedPath); + Console.Error.WriteLine($"Error [{CommandErrorCodes.DbNotFound}]: database not found at {displayPath}"); if (isUri) - Console.Error.WriteLine($"Hint: the --db path resolved to: {resolvedPath}"); + Console.Error.WriteLine($"Hint: the --db path resolved to: {displayPath}"); Console.Error.WriteLine("Hint: create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun this command."); return CommandExitCodes.DatabaseError; } @@ -7487,7 +7488,7 @@ private static int WithDb(QueryCommandOptions options, JsonSerializerOptions jso private static int WriteInvalidCodeIndexDbError(string dbPath, string? validationReason) { - Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: {dbPath} does not appear to be a valid CodeIndex database ({validationReason})."); + Console.Error.WriteLine($"Error [{CommandErrorCodes.DbError}]: {FormatDbDiagnosticValue(dbPath)} does not appear to be a valid CodeIndex database ({validationReason})."); Console.Error.WriteLine("Hint: rebuild with `cdidx index --db ` to create a fresh database."); return CommandExitCodes.DatabaseError; } @@ -7575,6 +7576,17 @@ private static string FormatLogValue(string? value) .Replace("\t", " ", StringComparison.Ordinal); } + private static string FormatDbDiagnosticValue(string? value) + { + if (string.IsNullOrEmpty(value)) + return ""; + + return SqliteFileUri.TruncateDiagnosticValue(value) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal) + .Replace("\t", " ", StringComparison.Ordinal); + } + private static void WriteProfilePayload(IReadOnlyList entries, JsonSerializerOptions jsonOptions) { var phases = new JsonArray(); @@ -7703,7 +7715,7 @@ private static bool TryWriteParseError(QueryCommandOptions options, string comma if (File.Exists(LongPath.EnsureWindowsPrefix(options.DbPath))) return null; - return $"Error [{CommandErrorCodes.DbNotFound}]: --db '{options.DbPath}' does not point to an existing database file."; + return $"Error [{CommandErrorCodes.DbNotFound}]: --db '{FormatDbDiagnosticValue(options.DbPath)}' does not point to an existing database file."; } private static readonly HashSet KnownSymbolKindFilters = new(StringComparer.Ordinal) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 5c28917ba7..0513058e2f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -1641,6 +1641,63 @@ public void RunBatch_UnsupportedOptionFlattensMultilineToken() Assert.DoesNotContain(token, stderr); } + [Fact] + public void WithDb_MissingOversizedPathReturnsBoundedDiagnostics_Issue3093() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_issue3093_missing_db"); + try + { + var missingDbPath = Path.Combine( + projectRoot, + Path.Combine(Enumerable.Repeat("segment", 40).ToArray()), + "codeindex.db"); + var resolvedPath = Path.GetFullPath(missingDbPath); + Assert.True(resolvedPath.Length > SqliteFileUri.MaxDiagnosticValueLength); + + var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--db", missingDbPath], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains($"Error [{CommandErrorCodes.DbNotFound}]: --db '", stderr); + Assert.Contains("does not point to an existing database file", stderr); + Assert.Contains("...(truncated,", stderr); + Assert.DoesNotContain(resolvedPath, stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunBatch_MissingOversizedDbPathReturnsBoundedDiagnostics_Issue3093() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_issue3093_batch_db"); + try + { + var missingDbPath = Path.Combine( + projectRoot, + Path.Combine(Enumerable.Repeat("segment", 40).ToArray()), + "codeindex.db"); + var resolvedPath = Path.GetFullPath(missingDbPath); + Assert.True(resolvedPath.Length > SqliteFileUri.MaxDiagnosticValueLength); + + var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunBatch( + ["--db", missingDbPath], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Contains($"Error [{CommandErrorCodes.DbNotFound}]: database not found at ", stderr); + Assert.Contains("...(truncated,", stderr); + Assert.DoesNotContain(resolvedPath, stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void WithDb_SqliteCantOpenSurfacesAccessOpenCategory_Issue2072() { From 0084019fa9facf49d8b5cf0713db9d851f760ad3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 6 Jun 2026 01:37:55 +0900 Subject: [PATCH 4/8] Warn for invalid CDIDX_NOTIFY (#3135) --- changelog.d/unreleased/3135.fixed.md | 16 ++++++++++ src/CodeIndex/Cli/IndexCommandRunner.Parse.cs | 17 ++++++++-- .../IndexCommandRunnerTests.cs | 31 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/3135.fixed.md diff --git a/changelog.d/unreleased/3135.fixed.md b/changelog.d/unreleased/3135.fixed.md new file mode 100644 index 0000000000..66f7518a5b --- /dev/null +++ b/changelog.d/unreleased/3135.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3135 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.Parse.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +--- + +## English + +- **Invalid `CDIDX_NOTIFY` values now warn before falling back (#3135)** — index argument parsing reports bounded stderr warnings for unsupported notification modes instead of silently ignoring the environment value. + +## 日本語 + +- **無効な `CDIDX_NOTIFY` 値は fallback 前に warning を出すようになりました (#3135)** — index の引数解析は未対応の notification mode を黙って無視せず、上限付きの stderr warning として報告します。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs index 059f2fd0fa..48e974df8f 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs @@ -21,6 +21,7 @@ public static partial class IndexCommandRunner "--read-only", "--immutable", ]; + internal const string CompletionNotificationEnvironmentVariable = "CDIDX_NOTIFY"; internal const string IndexParallelismEnvironmentVariable = "CDIDX_INDEX_PARALLELISM"; internal const int MaxIndexParallelism = 16; internal const int MaxSymbolKindFilterCsvLength = 2048; @@ -524,12 +525,16 @@ private static DurationOutputFormat WarnInvalidDurationFormat(string value, Dura private static CompletionNotificationMode ReadCompletionNotificationModeFromEnvironment() { - var value = Environment.GetEnvironmentVariable("CDIDX_NOTIFY"); + var value = Environment.GetEnvironmentVariable(CompletionNotificationEnvironmentVariable); if (string.IsNullOrWhiteSpace(value)) return CompletionNotificationMode.Auto; - string? ignored = null; - return ParseCompletionNotificationMode(value, CompletionNotificationMode.Auto, ref ignored); + string? parseError = null; + var mode = ParseCompletionNotificationMode(value, CompletionNotificationMode.Auto, ref parseError); + if (parseError != null) + WarnInvalidCompletionNotificationEnvironmentValue(value); + + return mode; } private static CompletionNotificationMode ParseCompletionNotificationMode(string value, CompletionNotificationMode fallback, ref string? parseError) @@ -551,6 +556,12 @@ private static CompletionNotificationMode WarnInvalidCompletionNotificationMode( return fallback; } + private static void WarnInvalidCompletionNotificationEnvironmentValue(string value) + { + var displayValue = ConsoleUi.FormatBoundedValue(value); + Console.Error.WriteLine($"Warning: invalid {CompletionNotificationEnvironmentVariable} value '{displayValue}' (ignored; use auto, bell, osc9, desktop, or none) / 不正な {CompletionNotificationEnvironmentVariable} 値 '{displayValue}'(無視。auto, bell, osc9, desktop, none のいずれかを指定)"); + } + private static string? AbsolutizePathOption(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 624aaa9509..6c3251ebdf 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -880,6 +880,37 @@ public void ParseArgs_NotifyFlag_ParsesCompletionNotificationMode() Assert.Null(options.ParseError); } + [Fact] + public void ParseArgs_InvalidNotifyEnvironmentWarnsAndFallsBack_Issue3135() + { + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture(IndexCommandRunner.CompletionNotificationEnvironmentVariable); + var originalErr = Console.Error; + using var stderr = new StringWriter(); + var value = new string('x', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); + try + { + env.Set(IndexCommandRunner.CompletionNotificationEnvironmentVariable, value); + Console.SetError(stderr); + + var options = IndexCommandRunner.ParseArgs(["."]); + + Assert.Equal(CompletionNotificationMode.Auto, options.NotifyMode); + Assert.Null(options.ParseError); + var warning = stderr.ToString(); + Assert.Contains($"invalid {IndexCommandRunner.CompletionNotificationEnvironmentVariable} value", warning); + Assert.Contains("ignored; use auto, bell, osc9, desktop, or none", warning); + Assert.Contains(" Date: Sat, 6 Jun 2026 01:41:08 +0900 Subject: [PATCH 5/8] Bound MCP rate limit warnings (#3090) --- changelog.d/unreleased/3090.fixed.md | 16 +++++ src/CodeIndex/Mcp/RateLimiter.cs | 13 ++-- tests/CodeIndex.Tests/RateLimiterTests.cs | 85 +++++++++++++++++++++++ 3 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/3090.fixed.md diff --git a/changelog.d/unreleased/3090.fixed.md b/changelog.d/unreleased/3090.fixed.md new file mode 100644 index 0000000000..1867c24b3c --- /dev/null +++ b/changelog.d/unreleased/3090.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3090 +affected: + - src/CodeIndex/Mcp/RateLimiter.cs + - tests/CodeIndex.Tests/RateLimiterTests.cs +--- + +## English + +- **MCP rate-limiter environment warnings now truncate oversized values (#3090)** — invalid or clamped `CDIDX_MCP_RATE_LIMIT_*` diagnostics bound the echoed environment value before writing warnings. + +## 日本語 + +- **MCP rate limiter の環境変数 warning は過大な値を切り詰めるようになりました (#3090)** — invalid / clamp される `CDIDX_MCP_RATE_LIMIT_*` 診断は、warning に出す環境変数値を上限付き表示にします。 diff --git a/src/CodeIndex/Mcp/RateLimiter.cs b/src/CodeIndex/Mcp/RateLimiter.cs index a30f5afbff..ec3bc70280 100644 --- a/src/CodeIndex/Mcp/RateLimiter.cs +++ b/src/CodeIndex/Mcp/RateLimiter.cs @@ -1,4 +1,5 @@ using System.Globalization; +using CodeIndex.Cli; namespace CodeIndex.Mcp; @@ -222,12 +223,12 @@ public static RateLimiterOptions FromEnvironment(Func? envReade if (!TryParsePositiveDouble(rpsRaw, out var rps)) { - warningSink($"[cdidx-mcp] Ignoring invalid {RpsEnvVar}='{rpsRaw}'. Expected a positive number (tokens per second). Rate limiting stays disabled."); + warningSink($"[cdidx-mcp] Ignoring invalid {RpsEnvVar}='{FormatEnvironmentValue(rpsRaw)}'. Expected a positive number (tokens per second). Rate limiting stays disabled."); return Disabled; } if (rps > MaxRefillTokensPerSecond) { - warningSink($"[cdidx-mcp] Clamping {RpsEnvVar}='{rpsRaw}' to maximum {MaxRefillTokensPerSecond.ToString(CultureInfo.InvariantCulture)} tokens per second."); + warningSink($"[cdidx-mcp] Clamping {RpsEnvVar}='{FormatEnvironmentValue(rpsRaw)}' to maximum {MaxRefillTokensPerSecond.ToString(CultureInfo.InvariantCulture)} tokens per second."); rps = MaxRefillTokensPerSecond; } @@ -243,23 +244,25 @@ public static RateLimiterOptions FromEnvironment(Func? envReade } else if (!TryParsePositiveDouble(burstRaw, out burst)) { - warningSink($"[cdidx-mcp] Ignoring invalid {BurstEnvVar}='{burstRaw}'. Expected a positive number (bucket capacity). Falling back to default burst."); + warningSink($"[cdidx-mcp] Ignoring invalid {BurstEnvVar}='{FormatEnvironmentValue(burstRaw)}'. Expected a positive number (bucket capacity). Falling back to default burst."); burst = Math.Max(rps, 1.0); } else if (burst > MaxBurstCapacity) { - warningSink($"[cdidx-mcp] Clamping {BurstEnvVar}='{burstRaw}' to maximum {MaxBurstCapacity.ToString(CultureInfo.InvariantCulture)} tokens."); + warningSink($"[cdidx-mcp] Clamping {BurstEnvVar}='{FormatEnvironmentValue(burstRaw)}' to maximum {MaxBurstCapacity.ToString(CultureInfo.InvariantCulture)} tokens."); burst = MaxBurstCapacity; } var bucketIdleTtl = DefaultBucketIdleTtl; var bucketIdleRaw = envReader(BucketIdleSecondsEnvVar); if (!string.IsNullOrWhiteSpace(bucketIdleRaw) && !TryParsePositiveTimeSpanSeconds(bucketIdleRaw, out bucketIdleTtl)) - warningSink($"[cdidx-mcp] Ignoring invalid {BucketIdleSecondsEnvVar}='{bucketIdleRaw}'. Expected a positive finite number of seconds. Falling back to the default bucket idle TTL."); + warningSink($"[cdidx-mcp] Ignoring invalid {BucketIdleSecondsEnvVar}='{FormatEnvironmentValue(bucketIdleRaw)}'. Expected a positive finite number of seconds. Falling back to the default bucket idle TTL."); return new RateLimiterOptions { RefillTokensPerSecond = rps, BurstCapacity = burst, BucketIdleTtl = bucketIdleTtl }; } + private static string FormatEnvironmentValue(string value) => ConsoleUi.FormatBoundedValue(value); + private static bool TryParsePositiveDouble(string raw, out double value) { if (double.TryParse(raw.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value) diff --git a/tests/CodeIndex.Tests/RateLimiterTests.cs b/tests/CodeIndex.Tests/RateLimiterTests.cs index 2aaa9514a1..7fb8a22739 100644 --- a/tests/CodeIndex.Tests/RateLimiterTests.cs +++ b/tests/CodeIndex.Tests/RateLimiterTests.cs @@ -1,3 +1,4 @@ +using CodeIndex.Cli; using CodeIndex.Mcp; namespace CodeIndex.Tests; @@ -310,6 +311,90 @@ public void FromEnvironment_TooLargeRps_ClampsAndWarns() Assert.Contains("Clamping CDIDX_MCP_RATE_LIMIT_RPS", warnings[0]); } + [Fact] + public void FromEnvironment_OversizedInvalidRps_TruncatesWarning_Issue3090() + { + var value = new string('x', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); + var warnings = new List(); + + var opts = RateLimiterOptions.FromEnvironment( + key => key == RateLimiterOptions.RpsEnvVar ? value : null, + warnings.Add); + + Assert.False(opts.IsEnabled); + var warning = Assert.Single(warnings); + Assert.Contains("Ignoring invalid CDIDX_MCP_RATE_LIMIT_RPS", warning); + Assert.Contains("(); + + var opts = RateLimiterOptions.FromEnvironment( + key => key == RateLimiterOptions.RpsEnvVar ? value : null, + warnings.Add); + + Assert.False(opts.IsEnabled); + var warning = Assert.Single(warnings); + Assert.Contains("bad forged value", warning); + Assert.DoesNotContain("\n", warning); + Assert.DoesNotContain("\r", warning); + Assert.DoesNotContain("\t", warning); + } + + [Fact] + public void FromEnvironment_OversizedTooLargeRps_TruncatesWarning_Issue3090() + { + var value = new string('9', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); + var warnings = new List(); + + var opts = RateLimiterOptions.FromEnvironment( + key => key == RateLimiterOptions.RpsEnvVar ? value : null, + warnings.Add); + + Assert.True(opts.IsEnabled); + Assert.Equal(RateLimiterOptions.MaxRefillTokensPerSecond, opts.RefillTokensPerSecond); + var warning = Assert.Single(warnings); + Assert.Contains("Clamping CDIDX_MCP_RATE_LIMIT_RPS", warning); + Assert.Contains("(); + + var opts = RateLimiterOptions.FromEnvironment( + key => key switch + { + RateLimiterOptions.RpsEnvVar => "2", + RateLimiterOptions.BurstEnvVar => burstValue, + RateLimiterOptions.BucketIdleSecondsEnvVar => bucketIdleValue, + _ => null, + }, + warnings.Add); + + Assert.True(opts.IsEnabled); + Assert.Equal(2.0, opts.BurstCapacity); + Assert.Equal(RateLimiterOptions.DefaultBucketIdleTtl, opts.BucketIdleTtl); + Assert.Equal(2, warnings.Count); + Assert.Contains(warnings, warning => + warning.Contains("Ignoring invalid CDIDX_MCP_RATE_LIMIT_BURST", StringComparison.Ordinal) + && warning.Contains(" + warning.Contains("Ignoring invalid CDIDX_MCP_RATE_LIMIT_BUCKET_IDLE_SECONDS", StringComparison.Ordinal) + && warning.Contains(" Date: Sat, 6 Jun 2026 01:44:23 +0900 Subject: [PATCH 6/8] Bound MCP keep-alive warnings (#3091) --- changelog.d/unreleased/3091.fixed.md | 16 ++++++++++++++ src/CodeIndex/Mcp/McpServer.cs | 2 +- tests/CodeIndex.Tests/McpServerTests.cs | 28 +++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3091.fixed.md diff --git a/changelog.d/unreleased/3091.fixed.md b/changelog.d/unreleased/3091.fixed.md new file mode 100644 index 0000000000..67ee5652d6 --- /dev/null +++ b/changelog.d/unreleased/3091.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3091 +affected: + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **MCP keep-alive interval warnings now truncate oversized environment values (#3091)** — invalid `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` diagnostics bound the echoed raw value before writing stderr. + +## 日本語 + +- **MCP keep-alive interval warning は過大な環境変数値を切り詰めるようになりました (#3091)** — invalid な `CDIDX_MCP_KEEP_ALIVE_INTERVAL_S` 診断は、stderr に出す raw 値を上限付き表示にします。 diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index aa39522dfb..fd51f8ad28 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -1365,7 +1365,7 @@ private string BuildKeepAliveNotificationJson() || seconds > MaxKeepAliveIntervalSeconds) { Console.Error.WriteLine( - $"[cdidx-mcp] Ignoring invalid {KeepAliveIntervalEnvironmentVariable}='{raw}'. Expected a finite value between {MinKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} and {MaxKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} seconds. Keep-alive notifications stay disabled."); + $"[cdidx-mcp] Ignoring invalid {KeepAliveIntervalEnvironmentVariable}='{ConsoleUi.FormatBoundedValue(raw)}'. Expected a finite value between {MinKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} and {MaxKeepAliveIntervalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture)} seconds. Keep-alive notifications stay disabled."); return null; } return TimeSpan.FromSeconds(seconds); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 59440d6a16..23f28dcee2 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6360,6 +6360,34 @@ public void Constructor_InvalidKeepAliveEnvironment_DoesNotThrow() Assert.Equal("ok", response["result"]!["status"]!.GetValue()); } + [Fact] + public void Constructor_InvalidKeepAliveEnvironment_TruncatesWarning_Issue3091() + { + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S"); + var originalErr = Console.Error; + using var stderr = new StringWriter(); + var value = new string('x', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); + try + { + env.Set("CDIDX_MCP_KEEP_ALIVE_INTERVAL_S", value); + Console.SetError(stderr); + + using var server = new McpServer(_dbPath, "1.0", dbPathExplicit: true); + + var warning = stderr.ToString(); + Assert.Contains("Ignoring invalid CDIDX_MCP_KEEP_ALIVE_INTERVAL_S", warning); + Assert.Contains(" Date: Sat, 6 Jun 2026 01:47:31 +0900 Subject: [PATCH 7/8] Bound batch row skip warnings (#3094) --- changelog.d/unreleased/3094.fixed.md | 16 ++++++++++++++++ src/CodeIndex/Database/DbWriter.cs | 16 +++++++++++++++- tests/CodeIndex.Tests/DatabaseTests.cs | 21 +++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3094.fixed.md diff --git a/changelog.d/unreleased/3094.fixed.md b/changelog.d/unreleased/3094.fixed.md new file mode 100644 index 0000000000..429755602d --- /dev/null +++ b/changelog.d/unreleased/3094.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3094 +affected: + - src/CodeIndex/Database/DbWriter.cs + - tests/CodeIndex.Tests/DatabaseTests.cs +--- + +## English + +- **Batch-row skip warnings now truncate oversized row details (#3094)** — `DbWriter` bounds row identifiers and batch/row exception messages before emitting skipped-row diagnostics. + +## 日本語 + +- **batch-row skip warning は過大な row 詳細を切り詰めるようになりました (#3094)** — `DbWriter` は skipped-row 診断を出す前に row identifier と batch / row exception message を上限付き表示にします。 diff --git a/src/CodeIndex/Database/DbWriter.cs b/src/CodeIndex/Database/DbWriter.cs index 0d7b21b151..5446eecaa0 100644 --- a/src/CodeIndex/Database/DbWriter.cs +++ b/src/CodeIndex/Database/DbWriter.cs @@ -1038,7 +1038,7 @@ private static bool IsRowSkippableSqliteException(SqliteException ex) private void WarnSkippedBatchRow(string rowIdentifier, Exception batchException, Exception rowException) { Interlocked.Increment(ref _batchRowsSkipped); - var message = $"Warning: skipped failed batch row ({rowIdentifier}); batch_error={batchException.Message}; row_error={rowException.Message}"; + var message = BuildBatchRowSkipWarning(rowIdentifier, batchException, rowException); var testSink = BatchRowSkipWarningForTesting; if (testSink != null) testSink(message); @@ -1046,6 +1046,20 @@ private void WarnSkippedBatchRow(string rowIdentifier, Exception batchException, Console.Error.WriteLine(message); } + internal static string BuildBatchRowSkipWarningForTesting(string rowIdentifier, Exception batchException, Exception rowException) + => BuildBatchRowSkipWarning(rowIdentifier, batchException, rowException); + + private static string BuildBatchRowSkipWarning(string rowIdentifier, Exception batchException, Exception rowException) + => $"Warning: skipped failed batch row ({FormatBatchRowSkipDiagnosticValue(rowIdentifier)}); batch_error={FormatBatchRowSkipDiagnosticValue(batchException.Message)}; row_error={FormatBatchRowSkipDiagnosticValue(rowException.Message)}"; + + private static string FormatBatchRowSkipDiagnosticValue(string? value) + { + return ConsoleUi.FormatBoundedValue(value) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal) + .Replace("\t", " ", StringComparison.Ordinal); + } + private void InsertChunkBatch(IReadOnlyList chunks, int start, int end) { using var cmd = _conn.CreateCommand(); diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index 2ee6408da5..6e700c5a06 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -1802,6 +1802,27 @@ public void InsertSymbols_BatchFailureSkipsOnlyBadRow() Assert.Contains("fn_with_bad_row_50", warning, StringComparison.Ordinal); } + [Fact] + public void BatchRowSkipWarning_TruncatesOversizedDiagnostics_Issue3094() + { + var rowValue = new string('r', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); + var batchValue = new string('b', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); + var rowErrorValue = new string('e', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); + + var warning = DbWriter.BuildBatchRowSkipWarningForTesting( + $"symbol file_id=1 name={rowValue} line=42", + new InvalidOperationException(batchValue), + new InvalidOperationException(rowErrorValue)); + + Assert.Contains("Warning: skipped failed batch row", warning, StringComparison.Ordinal); + Assert.Contains("batch_error=", warning, StringComparison.Ordinal); + Assert.Contains("row_error=", warning, StringComparison.Ordinal); + Assert.Contains(" Date: Sat, 6 Jun 2026 01:51:00 +0900 Subject: [PATCH 8/8] Bound MAC profile diagnostics (#3095) --- changelog.d/unreleased/3095.fixed.md | 16 +++++++ src/CodeIndex/Cli/MacProfileDetector.cs | 43 +++++++++++++++--- .../MacProfileDetectorTests.cs | 44 +++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/3095.fixed.md diff --git a/changelog.d/unreleased/3095.fixed.md b/changelog.d/unreleased/3095.fixed.md new file mode 100644 index 0000000000..cb0737672d --- /dev/null +++ b/changelog.d/unreleased/3095.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3095 +affected: + - src/CodeIndex/Cli/MacProfileDetector.cs + - tests/CodeIndex.Tests/MacProfileDetectorTests.cs +--- + +## English + +- **MAC profile detection now bounds proc-attr reads and hint output (#3095)** — `/proc/self/attr/*` reads are capped, and oversized AppArmor/SELinux profile strings are truncated before appearing in database-access hints. + +## 日本語 + +- **MAC profile 検出は proc-attr 読み取りと hint 出力を上限付きにしました (#3095)** — `/proc/self/attr/*` の読み取りを制限し、過大な AppArmor / SELinux profile 文字列は database-access hint に出す前に切り詰めます。 diff --git a/src/CodeIndex/Cli/MacProfileDetector.cs b/src/CodeIndex/Cli/MacProfileDetector.cs index a264811d53..2e4942cf95 100644 --- a/src/CodeIndex/Cli/MacProfileDetector.cs +++ b/src/CodeIndex/Cli/MacProfileDetector.cs @@ -1,4 +1,5 @@ using System.Runtime.InteropServices; +using System.Text; using Microsoft.Data.Sqlite; namespace CodeIndex.Cli; @@ -7,9 +8,10 @@ internal static class MacProfileDetector { internal const string CurrentAttrPath = "/proc/self/attr/current"; internal const string ExecAttrPath = "/proc/self/attr/exec"; + internal const int MaxProcAttrReadChars = 4096; public static string? DetectCurrent() - => DetectCurrent(File.ReadAllText); + => DetectCurrent(ReadProcAttrFile); internal static string? DetectCurrent(Func readAllText) { @@ -24,6 +26,9 @@ internal static class MacProfileDetector internal static string? DetectFromProcAttrs(string? current, string? exec) { + current = BoundProcAttrValue(current); + exec = BoundProcAttrValue(exec); + var appArmor = TryExtractAppArmorProfile(current) ?? TryExtractAppArmorProfile(exec); if (appArmor != null) return $"apparmor:{appArmor}"; @@ -37,23 +42,26 @@ internal static string BuildDatabaseHint(string? profile) if (string.IsNullOrWhiteSpace(profile)) return "Hint: check that `--db` points to a readable SQLite file, verify parent directory permissions, move the index to a writable location, or use a SQLite `file:` URI with `immutable=1` for read-only mounts."; + var displayProfile = FormatProfileForHint(profile); if (profile.StartsWith("apparmor:", StringComparison.OrdinalIgnoreCase)) - return $"Hint: this looks like an AppArmor confinement restriction ({profile}); check `aa-status`, snap/flatpak permissions, and audit logs, move the index to a writable location, or use a SQLite `file:` URI with `immutable=1` for read-only mounts."; + return $"Hint: this looks like an AppArmor confinement restriction ({displayProfile}); check `aa-status`, snap/flatpak permissions, and audit logs, move the index to a writable location, or use a SQLite `file:` URI with `immutable=1` for read-only mounts."; if (profile.StartsWith("selinux:", StringComparison.OrdinalIgnoreCase)) - return $"Hint: this looks like an SELinux confinement restriction ({profile}); check `getenforce`, `ausearch`, and `audit2why`, move the index to a writable location, or use a SQLite `file:` URI with `immutable=1` for read-only mounts."; + return $"Hint: this looks like an SELinux confinement restriction ({displayProfile}); check `getenforce`, `ausearch`, and `audit2why`, move the index to a writable location, or use a SQLite `file:` URI with `immutable=1` for read-only mounts."; - return $"Hint: this looks like a Linux MAC confinement restriction ({profile}); check AppArmor/SELinux audit logs, move the index to a writable location, or use a SQLite `file:` URI with `immutable=1` for read-only mounts."; + return $"Hint: this looks like a Linux MAC confinement restriction ({displayProfile}); check AppArmor/SELinux audit logs, move the index to a writable location, or use a SQLite `file:` URI with `immutable=1` for read-only mounts."; } internal static bool IsPermissionStyleSqliteError(SqliteException ex) => ex.SqliteErrorCode is 3 or 10 or 14 or 23; + internal static string ReadProcAttrFileForTesting(string path) => ReadProcAttrFile(path); + private static string? ReadProcAttr(Func readAllText, string path) { try { - var value = readAllText(path).Trim(); + var value = BoundProcAttrValue(readAllText(path))?.Trim(); return string.IsNullOrWhiteSpace(value) ? null : value; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or NotSupportedException) @@ -62,6 +70,31 @@ internal static bool IsPermissionStyleSqliteError(SqliteException ex) } } + private static string ReadProcAttrFile(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: false); + var buffer = new char[MaxProcAttrReadChars]; + var read = reader.ReadBlock(buffer, 0, buffer.Length); + return new string(buffer, 0, read); + } + + private static string? BoundProcAttrValue(string? value) + { + if (value == null) + return null; + + return value.Length <= MaxProcAttrReadChars ? value : value[..MaxProcAttrReadChars]; + } + + private static string FormatProfileForHint(string profile) + { + return ConsoleUi.FormatBoundedValue(profile) + .Replace("\r", " ", StringComparison.Ordinal) + .Replace("\n", " ", StringComparison.Ordinal) + .Replace("\t", " ", StringComparison.Ordinal); + } + private static string? TryExtractAppArmorProfile(string? value) { if (string.IsNullOrWhiteSpace(value) || value == "unconfined") diff --git a/tests/CodeIndex.Tests/MacProfileDetectorTests.cs b/tests/CodeIndex.Tests/MacProfileDetectorTests.cs index 9214f56018..bf0c58cd7c 100644 --- a/tests/CodeIndex.Tests/MacProfileDetectorTests.cs +++ b/tests/CodeIndex.Tests/MacProfileDetectorTests.cs @@ -18,6 +18,20 @@ public void BuildDatabaseHint_AppArmor_NamesAuditTools() Assert.Contains("aa-status", hint); } + [Fact] + public void BuildDatabaseHint_AppArmor_TruncatesOversizedProfile_Issue3095() + { + var profileTail = new string('a', ConsoleUi.DefaultDiagnosticValueCharLimit + 1); + var profile = "apparmor:" + profileTail; + + var hint = MacProfileDetector.BuildDatabaseHint(profile); + + Assert.Contains("AppArmor", hint); + Assert.Contains("