diff --git a/changelog.d/unreleased/3109.security.md b/changelog.d/unreleased/3109.security.md new file mode 100644 index 0000000000..82d7da90ca --- /dev/null +++ b/changelog.d/unreleased/3109.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3109 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Doctor output now truncates oversized environment values (#3109)** — `cdidx doctor` bounds terminal and `CDIDX_*` environment values before printing them, with an explicit original-length marker when truncation occurs. + +## 日本語 + +- **doctor 出力で過大な環境変数値を短縮するようになりました (#3109)** — `cdidx doctor` は terminal および `CDIDX_*` 環境変数値を表示前に制限し、短縮時は元の長さを示す marker を付けます。 diff --git a/changelog.d/unreleased/3110.security.md b/changelog.d/unreleased/3110.security.md new file mode 100644 index 0000000000..9b2905a281 --- /dev/null +++ b/changelog.d/unreleased/3110.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3110 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Query default environment parse errors now truncate raw values (#3110)** — invalid `CDIDX_DEFAULT_*` numeric defaults report a bounded display value instead of embedding an arbitrarily long environment string. + +## 日本語 + +- **query default 環境変数の parse error で raw 値を短縮するようになりました (#3110)** — 不正な `CDIDX_DEFAULT_*` 数値 default は、任意長の環境変数文字列をそのまま埋め込まず、制限済みの表示値を報告します。 diff --git a/changelog.d/unreleased/3123.security.md b/changelog.d/unreleased/3123.security.md new file mode 100644 index 0000000000..01c577cbb5 --- /dev/null +++ b/changelog.d/unreleased/3123.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3123 +affected: + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Query trace parameters now cap captured values and path arrays (#3123)** — trace JSON bounds parameter strings and repeated `--path` / `--exclude-path` arrays, and emits truncation metadata when values or counts are reduced. + +## 日本語 + +- **query trace parameter が取得値と path 配列を制限するようになりました (#3123)** — trace JSON は parameter 文字列と繰り返し指定された `--path` / `--exclude-path` 配列を制限し、値や件数が削減された場合は truncation metadata を出します。 diff --git a/changelog.d/unreleased/3164.security.md b/changelog.d/unreleased/3164.security.md new file mode 100644 index 0000000000..ec9e2b8dc9 --- /dev/null +++ b/changelog.d/unreleased/3164.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3164 +affected: + - src/CodeIndex/Cli/ActiveWorkspace.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +--- + +## English + +- **Active workspace environment loading now caps and validates paths (#3164)** — oversized or invalid `CDIDX_ACTIVE_WORKSPACE` values are ignored with bounded warnings instead of throwing or leaking huge paths. + +## 日本語 + +- **active workspace の環境変数読み込みで path を制限・検証するようになりました (#3164)** — 過大または不正な `CDIDX_ACTIVE_WORKSPACE` 値は、例外や巨大 path の露出ではなく、制限済み warning とともに無視されます。 diff --git a/changelog.d/unreleased/3166.security.md b/changelog.d/unreleased/3166.security.md new file mode 100644 index 0000000000..3c6e2566ca --- /dev/null +++ b/changelog.d/unreleased/3166.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3166 +affected: + - src/CodeIndex/Cli/GlobalToolLog.cs + - tests/CodeIndex.Tests/GlobalToolLogTests.cs +--- + +## English + +- **Persistent stderr mirroring now truncates oversized writes (#3166)** — global tool logs bound each mirrored stderr string write while preserving the original console stderr output. + +## 日本語 + +- **永続 stderr mirror で過大な write を短縮するようになりました (#3166)** — global tool log は console stderr の元出力を維持しつつ、mirror される stderr 文字列 write ごとにサイズを制限します。 diff --git a/changelog.d/unreleased/3218.security.md b/changelog.d/unreleased/3218.security.md new file mode 100644 index 0000000000..a4eef9032a --- /dev/null +++ b/changelog.d/unreleased/3218.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3218 +affected: + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **`.cdidx-version` read warnings now avoid raw paths and exception messages (#3218)** — version-pin read failures and malformed pin warnings now report sanitized reasons without printing full local paths. + +## 日本語 + +- **`.cdidx-version` 読み取り warning が raw path と例外 message を避けるようになりました (#3218)** — version pin の読み取り失敗や不正 pin warning は、完全なローカル path を出さずに sanitize 済みの理由を報告します。 diff --git a/changelog.d/unreleased/3219.security.md b/changelog.d/unreleased/3219.security.md new file mode 100644 index 0000000000..85a0162228 --- /dev/null +++ b/changelog.d/unreleased/3219.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3219 +affected: + - src/CodeIndex/Cli/ActiveWorkspace.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +--- + +## English + +- **Active workspace state warnings now avoid raw paths and exception messages (#3219)** — malformed state files now produce sanitized warnings with reset guidance instead of printing the state path or provider exception text. + +## 日本語 + +- **active workspace state warning が raw path と例外 message を避けるようになりました (#3219)** — 不正な state file は、state path や provider 例外 text を出さず、reset guidance 付きの sanitize 済み warning を出します。 diff --git a/src/CodeIndex/Cli/ActiveWorkspace.cs b/src/CodeIndex/Cli/ActiveWorkspace.cs index 8ba2810336..074d53c707 100644 --- a/src/CodeIndex/Cli/ActiveWorkspace.cs +++ b/src/CodeIndex/Cli/ActiveWorkspace.cs @@ -8,6 +8,7 @@ internal sealed record ActiveWorkspaceState(string Name, string Root, string DbP internal static class ActiveWorkspace { internal const string EnvironmentVariable = "CDIDX_ACTIVE_WORKSPACE"; + internal const int MaxEnvironmentPathChars = 4096; private const int MaxStateBytes = 64 * 1024; internal const int MaxStateJsonDepth = 16; private static readonly JsonDocumentOptions StateJsonDocumentOptions = new() @@ -31,7 +32,7 @@ internal static string StatePath { var envPath = Environment.GetEnvironmentVariable(EnvironmentVariable); if (!string.IsNullOrWhiteSpace(envPath)) - return new ActiveWorkspaceState("env", Path.GetDirectoryName(Path.GetFullPath(envPath)) ?? Environment.CurrentDirectory, Path.GetFullPath(envPath)); + return LoadFromEnvironment(envPath); var path = StatePath; if (!File.Exists(LongPath.EnsureWindowsPrefix(path))) @@ -42,7 +43,7 @@ internal static string StatePath var text = DataDirectorySecurity.ReadTextWithinLimit(path, MaxStateBytes, FileShare.ReadWrite); if (text is null) { - WriteLoadWarning(path, $"file exceeds {MaxStateBytes} bytes"); + WriteLoadWarning("state file", $"file exceeds {MaxStateBytes} bytes"); return null; } @@ -57,7 +58,7 @@ internal static string StatePath } catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { - WriteLoadWarning(path, ex.Message); + WriteLoadWarning("state file", DescribeLoadFailure(ex)); return null; } } @@ -72,6 +73,35 @@ internal static void Save(ActiveWorkspaceState state) private static string? ReadString(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; - private static void WriteLoadWarning(string path, string reason) - => Console.Error.WriteLine($"[cdidx] Ignoring active workspace state at {path}: {reason}"); + private static ActiveWorkspaceState? LoadFromEnvironment(string envPath) + { + if (envPath.Length > MaxEnvironmentPathChars) + { + WriteLoadWarning($"environment variable {EnvironmentVariable}", $"value exceeds {MaxEnvironmentPathChars} characters"); + return null; + } + + try + { + var fullPath = Path.GetFullPath(envPath); + return new ActiveWorkspaceState("env", Path.GetDirectoryName(fullPath) ?? Environment.CurrentDirectory, fullPath); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + WriteLoadWarning($"environment variable {EnvironmentVariable}", DescribeLoadFailure(ex)); + return null; + } + } + + private static string DescribeLoadFailure(Exception ex) => ex switch + { + JsonException => "invalid JSON", + UnauthorizedAccessException => "permission denied", + ArgumentException or NotSupportedException or PathTooLongException => "invalid path", + IOException => "read failed", + _ => "load failed", + }; + + private static void WriteLoadWarning(string source, string reason) + => Console.Error.WriteLine($"[cdidx] Ignoring active workspace {source}: {ConsoleUi.FormatBoundedValue(reason)}. Hint: inspect or reset the active workspace configuration."); } diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 4fa0b1708e..c0073268d5 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -127,6 +127,31 @@ private static readonly (string Command, string Note)[] CommandUsageNotes = public static string FormatSummaryLine(string label, object? value, int labelWidth = SummaryLabelWidth, string indent = "") => $"{indent}{label.PadRight(labelWidth)}: {value}"; + internal const int DefaultDiagnosticValueCharLimit = 120; + + internal readonly record struct BoundedDisplayText(string Text, bool Truncated, int OriginalLength); + + internal static BoundedDisplayText BoundDisplayText(string? value, int maxChars = DefaultDiagnosticValueCharLimit) + { + if (maxChars < 0) + throw new ArgumentOutOfRangeException(nameof(maxChars), maxChars, "Display limit must be non-negative."); + + if (value == null) + return new BoundedDisplayText("", Truncated: false, OriginalLength: 0); + + if (value.Length <= maxChars) + return new BoundedDisplayText(value, Truncated: false, value.Length); + + var marker = string.Create(CultureInfo.InvariantCulture, $"... "); + var text = maxChars == 0 + ? marker.TrimStart('.', ' ') + : value[..maxChars] + marker; + return new BoundedDisplayText(text, Truncated: true, value.Length); + } + + internal static string FormatBoundedValue(string? value, int maxChars = DefaultDiagnosticValueCharLimit) + => BoundDisplayText(value, maxChars).Text; + private const int SpinnerFrameDelayMs = 100; private const int SpinnerStopDelayMs = 20; private const int ConsoleLineMargin = 1; diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index 4bdbaf300f..e77ebc59a4 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -19,6 +19,7 @@ internal static class GlobalToolLog internal const string LogMaxSizeMbEnvironmentVariable = "CDIDX_LOG_MAX_SIZE_MB"; internal const string GlobalToolLogMaxBytesEnvironmentVariable = "CDIDX_GLOBAL_TOOL_LOG_MAX_BYTES"; private const long DefaultLogMaxSizeBytes = 50L * 1024L * 1024L; + internal const int MirroredStderrWriteMaxChars = 8192; internal const int MaxLogSizeMb = 1024; internal const long MaxLogSizeBytes = MaxLogSizeMb * 1024L * 1024L; internal const int RedactionArgumentLengthLimit = 8192; @@ -720,15 +721,18 @@ public override void Write(char value) public override void Write(string? value) { TryWrite(() => primary.Write(value)); - TryWrite(() => secondary.Write(value)); + TryWrite(() => secondary.Write(FormatMirroredWrite(value))); } public override void WriteLine(string? value) { TryWrite(() => primary.WriteLine(value)); - TryWrite(() => secondary.WriteLine(value)); + TryWrite(() => secondary.WriteLine(FormatMirroredWrite(value))); } + private static string? FormatMirroredWrite(string? value) + => value == null ? null : ConsoleUi.FormatBoundedValue(value, MirroredStderrWriteMaxChars); + private static void TryWrite(Action write) { try diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index abd3947539..2510ad8af9 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -17,6 +17,8 @@ namespace CodeIndex.Cli; internal static class ProgramRunner { private const int RetainedQueryTraceFileCount = 30; + internal const int QueryTraceValueMaxChars = 128; + internal const int QueryTraceArrayMaxItems = 8; internal const string QuietEnvironmentVariable = "CDIDX_QUIET"; private const string InstallerScriptUrlTemplate = "https://raw.githubusercontent.com/Widthdom/CodeIndex/{0}/install.sh"; private const long MaxInstallerScriptBytes = 1024 * 1024; @@ -603,9 +605,9 @@ private static int RunDoctor(string[] args, string appVersion) Console.WriteLine("terminal:"); Console.WriteLine(ConsoleUi.FormatSummaryLine("stdout_tty", !Console.IsOutputRedirected, indent: " ")); Console.WriteLine(ConsoleUi.FormatSummaryLine("stderr_tty", !Console.IsErrorRedirected, indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("columns", Environment.GetEnvironmentVariable("COLUMNS") ?? "", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("no_color", Environment.GetEnvironmentVariable("NO_COLOR") ?? "", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine("term", Environment.GetEnvironmentVariable("TERM") ?? "", indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("columns", FormatDoctorEnvironmentValue(Environment.GetEnvironmentVariable("COLUMNS")), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("no_color", FormatDoctorEnvironmentValue(Environment.GetEnvironmentVariable("NO_COLOR")), indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine("term", FormatDoctorEnvironmentValue(Environment.GetEnvironmentVariable("TERM")), indent: " ")); Console.WriteLine(ConsoleUi.FormatSummaryLine("locale", CultureInfo.CurrentCulture.Name, indent: " ")); Console.WriteLine(ConsoleUi.FormatSummaryLine("ui_locale", CultureInfo.CurrentUICulture.Name, indent: " ")); Console.WriteLine(); @@ -617,7 +619,7 @@ private static int RunDoctor(string[] args, string appVersion) Console.WriteLine(); Console.WriteLine("config:"); Console.WriteLine(ConsoleUi.FormatSummaryLine(CdidxConfigFile.FileName, File.Exists(Path.Combine(Environment.CurrentDirectory, CdidxConfigFile.FileName)) ? "present" : "not found", indent: " ")); - Console.WriteLine(ConsoleUi.FormatSummaryLine(CdidxConfigFile.DisableEnvVar, Environment.GetEnvironmentVariable(CdidxConfigFile.DisableEnvVar) ?? "", indent: " ")); + Console.WriteLine(ConsoleUi.FormatSummaryLine(CdidxConfigFile.DisableEnvVar, FormatDoctorEnvironmentValue(Environment.GetEnvironmentVariable(CdidxConfigFile.DisableEnvVar)), indent: " ")); Console.WriteLine(); Console.WriteLine("cdidx_env:"); foreach (var (key, value) in EnumerateCdidxEnvironment()) @@ -636,13 +638,16 @@ private static int RunDoctor(string[] args, string appVersion) foreach (var row in rows) { any = true; - yield return (row.Key, IsSensitiveEnvironmentName(row.Key) ? "" : string.IsNullOrEmpty(row.Value) ? "" : row.Value); + yield return (row.Key, IsSensitiveEnvironmentName(row.Key) ? "" : string.IsNullOrEmpty(row.Value) ? "" : ConsoleUi.FormatBoundedValue(row.Value)); } if (!any) yield return ("", ""); } + private static string FormatDoctorEnvironmentValue(string? value) + => value == null ? "" : ConsoleUi.FormatBoundedValue(value); + private static bool IsSensitiveEnvironmentName(string name) => name.Contains("TOKEN", StringComparison.OrdinalIgnoreCase) || name.Contains("PASSWORD", StringComparison.OrdinalIgnoreCase) @@ -1624,15 +1629,15 @@ private static bool TryReadWorkspaceVersionPin(string pinPath, out string requir var bytes = ReadWorkspaceVersionPinBytes(pinPath); if (bytes.Length > WorkspaceVersionPinMaxBytes) { - warning = $"Warning: ignoring .cdidx-version at {pinPath}: file exceeds {WorkspaceVersionPinMaxBytes} bytes."; + warning = BuildWorkspaceVersionPinWarning($"file exceeds {WorkspaceVersionPinMaxBytes} bytes"); return false; } - return TryParseWorkspaceVersionPin(DecodeWorkspaceVersionPinBytes(bytes), pinPath, out required, out warning); + return TryParseWorkspaceVersionPin(DecodeWorkspaceVersionPinBytes(bytes), out required, out warning); } catch (Exception ex) { - warning = $"Warning: could not read .cdidx-version at {pinPath}: {ex.Message}"; + warning = BuildWorkspaceVersionPinReadWarning(ex); return false; } } @@ -1677,7 +1682,7 @@ private static string DecodeWorkspaceVersionPinBytes(byte[] bytes) return reader.ReadToEnd(); } - private static bool TryParseWorkspaceVersionPin(string content, string pinPath, out string required, out string warning) + private static bool TryParseWorkspaceVersionPin(string content, out string required, out string warning) { required = string.Empty; warning = string.Empty; @@ -1691,7 +1696,7 @@ private static bool TryParseWorkspaceVersionPin(string content, string pinPath, lineNumber++; if (line.Length > WorkspaceVersionPinMaxLineChars) { - warning = $"Warning: ignoring .cdidx-version at {pinPath}: line {lineNumber} exceeds {WorkspaceVersionPinMaxLineChars} characters."; + warning = BuildWorkspaceVersionPinWarning($"line {lineNumber} exceeds {WorkspaceVersionPinMaxLineChars} characters"); return false; } @@ -1700,7 +1705,7 @@ private static bool TryParseWorkspaceVersionPin(string content, string pinPath, skippedBlankLines++; if (skippedBlankLines > WorkspaceVersionPinMaxSkippedBlankLines) { - warning = $"Warning: ignoring .cdidx-version at {pinPath}: more than {WorkspaceVersionPinMaxSkippedBlankLines} leading blank lines."; + warning = BuildWorkspaceVersionPinWarning($"more than {WorkspaceVersionPinMaxSkippedBlankLines} leading blank lines"); return false; } @@ -1714,6 +1719,24 @@ private static bool TryParseWorkspaceVersionPin(string content, string pinPath, return true; } + internal static string BuildWorkspaceVersionPinReadWarningForTesting(Exception exception) + => BuildWorkspaceVersionPinReadWarning(exception); + + private static string BuildWorkspaceVersionPinWarning(string reason) + => $"Warning: ignoring .cdidx-version: {ConsoleUi.FormatBoundedValue(reason)}."; + + private static string BuildWorkspaceVersionPinReadWarning(Exception exception) + { + var reason = exception switch + { + UnauthorizedAccessException => "permission denied", + ArgumentException or NotSupportedException or PathTooLongException => "invalid path", + IOException => "read failed", + _ => "read failed", + }; + return $"Warning: could not read .cdidx-version: {reason}."; + } + internal static string? FindWorkspaceVersionPin(string startDirectory) { var current = Path.GetFullPath(startDirectory); @@ -1866,7 +1889,7 @@ internal static bool TryConsumeQueryTraceFlag(ref string[] args, out string trac } if (rawValue is not ("none" or "stderr" or "file")) { - error = $"Error: --trace must be one of `none`, `stderr`, or `file`, got `{rawValue}`."; + error = $"Error: --trace must be one of `none`, `stderr`, or `file`, got `{ConsoleUi.FormatBoundedValue(rawValue)}`."; return false; } traceMode = rawValue; @@ -1975,17 +1998,17 @@ private static JsonObject BuildQueryTraceParameters(string[] args) case "--json": parameters["json"] = true; if (!string.IsNullOrWhiteSpace(value)) - parameters["json_format"] = value; + AddQueryTraceString(parameters, "json_format", value); break; case "--count": parameters["count"] = true; break; case "--lang" when !string.IsNullOrWhiteSpace(value): - parameters["lang"] = value; + AddQueryTraceString(parameters, "lang", value); break; case "--limit" when !string.IsNullOrWhiteSpace(value): case "--top" when !string.IsNullOrWhiteSpace(value): - parameters["limit"] = value; + AddQueryTraceString(parameters, "limit", value); break; case "--path" when !string.IsNullOrWhiteSpace(value): paths.Add(value); @@ -1995,13 +2018,47 @@ private static JsonObject BuildQueryTraceParameters(string[] args) break; } } - if (paths.Count > 0) - parameters["path"] = new JsonArray(paths.Select(path => JsonValue.Create(path)).ToArray()); - if (excludePaths.Count > 0) - parameters["exclude_path"] = new JsonArray(excludePaths.Select(path => JsonValue.Create(path)).ToArray()); + AddQueryTraceArray(parameters, "path", paths); + AddQueryTraceArray(parameters, "exclude_path", excludePaths); return parameters; } + private static void AddQueryTraceString(JsonObject parameters, string name, string value) + { + var bounded = ConsoleUi.BoundDisplayText(value, QueryTraceValueMaxChars); + parameters[name] = bounded.Text; + if (bounded.Truncated) + { + parameters[$"{name}_truncated"] = true; + parameters[$"{name}_original_length"] = bounded.OriginalLength; + } + } + + private static void AddQueryTraceArray(JsonObject parameters, string name, List values) + { + if (values.Count == 0) + return; + + var array = new JsonArray(); + var valueTruncated = false; + foreach (var value in values.Take(QueryTraceArrayMaxItems)) + { + var bounded = ConsoleUi.BoundDisplayText(value, QueryTraceValueMaxChars); + valueTruncated |= bounded.Truncated; + array.Add(JsonValue.Create(bounded.Text)); + } + + parameters[name] = array; + if (values.Count > QueryTraceArrayMaxItems) + { + parameters[$"{name}_truncated"] = true; + parameters[$"{name}_original_count"] = values.Count; + } + + if (valueTruncated) + parameters[$"{name}_value_truncated"] = true; + } + private sealed class QueryTraceOutputCapture : TextWriter { private readonly TextWriter _inner; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 29ad65b862..6f03fd5c90 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -8978,7 +8978,7 @@ private static int ResolveDefaultPositiveInt(string environmentVariable, int fal return fallback; } - if (TryParsePositiveInt(raw, optionName, out var value, out var parseError)) + if (TryParsePositiveInt(raw, optionName, out var value, out var parseError, ConsoleUi.FormatBoundedValue(raw))) { error = null; return value; @@ -8997,7 +8997,7 @@ private static int ResolveDefaultNonNegativeInt(string environmentVariable, int return fallback; } - if (TryParseNonNegativeInt(raw, optionName, out var value, out var parseError)) + if (TryParseNonNegativeInt(raw, optionName, out var value, out var parseError, ConsoleUi.FormatBoundedValue(raw))) { error = null; return value; @@ -9007,21 +9007,22 @@ private static int ResolveDefaultNonNegativeInt(string environmentVariable, int return fallback; } - private static bool TryParsePositiveInt(string rawValue, string optionName, out int value, out string? error) + private static bool TryParsePositiveInt(string rawValue, string optionName, out int value, out string? error, string? displayRawValue = null) { if (string.Equals(optionName, "--max-line-width", StringComparison.Ordinal)) - return TryParseNonNegativeInt(rawValue, optionName, out value, out error); + return TryParseNonNegativeInt(rawValue, optionName, out value, out error, displayRawValue); + displayRawValue ??= rawValue; if (!int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) || value <= 0) { value = 0; - error = BuildPositiveIntegerError(optionName, rawValue); + error = BuildPositiveIntegerError(optionName, displayRawValue); return false; } if (NumericFlagUpperBounds.TryGetValue(optionName, out var maxAllowed) && value > maxAllowed) { - error = BuildPositiveIntegerUpperBoundError(optionName, rawValue, maxAllowed); + error = BuildPositiveIntegerUpperBoundError(optionName, displayRawValue, maxAllowed); value = 0; return false; } @@ -9030,18 +9031,19 @@ private static bool TryParsePositiveInt(string rawValue, string optionName, out return true; } - private static bool TryParseNonNegativeInt(string rawValue, string optionName, out int value, out string? error) + private static bool TryParseNonNegativeInt(string rawValue, string optionName, out int value, out string? error, string? displayRawValue = null) { + displayRawValue ??= rawValue; if (!int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out value) || value < 0) { value = 0; - error = BuildNonNegativeIntegerError(optionName, rawValue); + error = BuildNonNegativeIntegerError(optionName, displayRawValue); return false; } if (NumericFlagUpperBounds.TryGetValue(optionName, out var maxAllowed) && value > maxAllowed) { - error = BuildNonNegativeIntegerUpperBoundError(optionName, rawValue, maxAllowed); + error = BuildNonNegativeIntegerUpperBoundError(optionName, displayRawValue, maxAllowed); value = 0; return false; } diff --git a/tests/CodeIndex.Tests/GlobalToolLogTests.cs b/tests/CodeIndex.Tests/GlobalToolLogTests.cs index 8a88f7dd3a..b37f04ee4a 100644 --- a/tests/CodeIndex.Tests/GlobalToolLogTests.cs +++ b/tests/CodeIndex.Tests/GlobalToolLogTests.cs @@ -293,6 +293,46 @@ public void TryStart_ErrorMirrorIgnoresDisposedOriginalConsoleWriter() } } + [Fact] + public void TryStart_ErrorMirrorTruncatesLargeWrites_Issue3166() + { + var logRoot = Path.Combine(Path.GetTempPath(), $"cdidx_global_log_large_mirror_{Guid.NewGuid():N}"); + var originalError = Console.Error; + var visibleError = new StringWriter(CultureInfo.InvariantCulture); + try + { + using var env = EnvironmentVariableScope.Capture( + "CDIDX_FORCE_GLOBAL_TOOL_LOG", + "CDIDX_DISABLE_PERSISTENT_LOG", + "CDIDX_GLOBAL_TOOL_LOG_DIR"); + env.Set("CDIDX_FORCE_GLOBAL_TOOL_LOG", "1"); + env.Set("CDIDX_DISABLE_PERSISTENT_LOG", null); + env.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", logRoot); + Console.SetError(visibleError); + var prefix = new string('e', GlobalToolLog.MirroredStderrWriteMaxChars); + const string tail = "TAIL_ISSUE_3166"; + var raw = prefix + tail; + + using (var session = GlobalToolLog.TryStartForTesting(["status"], "test")) + { + Assert.NotNull(session); + Console.Error.WriteLine(raw); + } + + Assert.Contains(tail, visibleError.ToString()); + var logPath = Directory.GetFiles(logRoot, "stderr-*.log", SearchOption.TopDirectoryOnly).Single(); + var log = File.ReadAllText(logPath); + Assert.Contains($"original length {raw.Length} chars", log); + Assert.DoesNotContain(tail, log); + } + finally + { + Console.SetError(originalError); + if (Directory.Exists(logRoot)) + Directory.Delete(logRoot, recursive: true); + } + } + private static void ThrowForGlobalToolLogTest() => throw new InvalidOperationException("global log stack trace test"); diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index f7bc10a4b8..e10cfaab68 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -224,6 +224,108 @@ public void Run_QueryTraceStderr_EmitsStructuredSanitizedLine() } } + [Fact] + public void RunDoctor_TruncatesTerminalEnvironmentValues_Issue3109() + { + var prefix = new string('c', ConsoleUi.DefaultDiagnosticValueCharLimit); + const string tail = "TAIL_ISSUE_3109"; + var raw = prefix + tail; + using var env = EnvironmentVariableScope.Capture("COLUMNS", "NO_COLOR", "TERM", "CDIDX_VISIBLE_LONG_VALUE"); + env.Set("COLUMNS", raw); + env.Set("NO_COLOR", raw); + env.Set("TERM", raw); + env.Set("CDIDX_VISIBLE_LONG_VALUE", raw); + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["doctor"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + Assert.Contains("terminal:", stdout); + Assert.Contains("cdidx_env:", stdout); + Assert.Contains($"original length {raw.Length} chars", stdout); + Assert.DoesNotContain(tail, stdout); + } + + [Fact] + public void Run_QueryTraceStderr_BoundsPathArraysAndValues_Issue3123() + { + var projectRoot = TestProjectHelper.CreateTempProject("query-trace-bounds"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "public class App { public void Needle() { } }"); + var longPath = new string('p', ProgramRunner.QueryTraceValueMaxChars) + "TAIL_ISSUE_3123"; + var args = new List + { + "search", + "Needle", + "--db", + dbPath, + "--trace=stderr", + "--count", + }; + for (var i = 0; i < ProgramRunner.QueryTraceArrayMaxItems + 3; i++) + { + args.Add("--path"); + args.Add(i == 0 ? longPath : $"src/{i}.cs"); + } + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + args.ToArray(), + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("0", stdout.Trim()); + var traceLine = stderr.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Single(line => line.StartsWith('{')); + using var document = JsonDocument.Parse(traceLine); + var parameters = document.RootElement.GetProperty("parameters"); + Assert.Equal(ProgramRunner.QueryTraceArrayMaxItems, parameters.GetProperty("path").GetArrayLength()); + Assert.True(parameters.GetProperty("path_truncated").GetBoolean()); + Assert.Equal(ProgramRunner.QueryTraceArrayMaxItems + 3, parameters.GetProperty("path_original_count").GetInt32()); + Assert.True(parameters.GetProperty("path_value_truncated").GetBoolean()); + Assert.Contains($"original length {longPath.Length} chars", parameters.GetProperty("path")[0].GetString()); + Assert.DoesNotContain("TAIL_ISSUE_3123", traceLine); + Assert.DoesNotContain(dbPath, traceLine); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void Run_QueryDefaultEnvironmentParseError_TruncatesRawValue_Issue3110() + { + var prefix = new string('9', ConsoleUi.DefaultDiagnosticValueCharLimit); + const string tail = "TAIL_ISSUE_3110"; + var raw = prefix + tail; + using var env = EnvironmentVariableScope.Capture(QueryCommandRunner.DefaultLimitEnvironmentVariable); + env.Set(QueryCommandRunner.DefaultLimitEnvironmentVariable, raw); + + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "Needle"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Empty(stdout); + Assert.Contains(QueryCommandRunner.DefaultLimitEnvironmentVariable, stderr); + Assert.Contains($"original length {raw.Length} chars", stderr); + Assert.DoesNotContain(tail, stderr); + } + + [Fact] + public void WorkspaceVersionPinReadWarning_SanitizesPathLikeExceptionMessages_Issue3218() + { + var warning = ProgramRunner.BuildWorkspaceVersionPinReadWarningForTesting( + new IOException("could not read /Users/alice/private/repo/.cdidx-version")); + + Assert.Equal("Warning: could not read .cdidx-version: read failed.", warning); + Assert.DoesNotContain("/Users/alice", warning); + Assert.DoesNotContain("private/repo", warning); + } + [Fact] public void Run_QueryTraceFile_AppendsDailyJsonl() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerMaxLineWidthTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerMaxLineWidthTests.cs index 19211203e5..0f3c84f5cf 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerMaxLineWidthTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerMaxLineWidthTests.cs @@ -18,6 +18,7 @@ public void TryParsePositiveInt_AllowsZeroForMaxLineWidth() "--max-line-width", 123, "placeholder", + null, ]; var result = (bool)TryParsePositiveInt.Invoke(null, args)!; @@ -36,6 +37,7 @@ public void TryParsePositiveInt_StillRejectsZeroForLimit() "--limit", 123, "placeholder", + null, ]; var result = (bool)TryParsePositiveInt.Invoke(null, args)!; diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 0f8ffd8aa1..23543035d5 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -349,6 +349,10 @@ public void MalformedActiveWorkspaceState_DoesNotOverrideQueryResolution() Assert.NotNull(query); Assert.Contains("Ignoring active workspace state", stderr); + Assert.DoesNotContain(configHome, stderr); + Assert.DoesNotContain(ActiveWorkspace.StatePath, stderr); + Assert.DoesNotContain("LineNumber", stderr); + Assert.Contains("invalid JSON", stderr); Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query!.DbPath); Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); } @@ -401,6 +405,36 @@ public void DeeplyNestedActiveWorkspaceState_DoesNotOverrideQueryResolution_Issu } } + [Fact] + public void OversizedActiveWorkspaceEnvironment_DoesNotOverrideQueryResolution_Issue3164() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_env_large_project"); + try + { + var raw = new string('a', ActiveWorkspace.MaxEnvironmentPathChars) + "TAIL_ISSUE_3164"; + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, raw); + + DbPathResolution? query = null; + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + return 0; + }); + + Assert.NotNull(query); + Assert.Contains(ActiveWorkspace.EnvironmentVariable, stderr); + Assert.Contains($"value exceeds {ActiveWorkspace.MaxEnvironmentPathChars} characters", stderr); + Assert.DoesNotContain("TAIL_ISSUE_3164", stderr); + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query!.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void ActiveWorkspaceSave_OnPosix_WritesPrivateStateFile() {