diff --git a/changelog.d/unreleased/3379.security.md b/changelog.d/unreleased/3379.security.md new file mode 100644 index 0000000000..f5683d9cc2 --- /dev/null +++ b/changelog.d/unreleased/3379.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3379 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- + +## English + +- **Temporary checkpoint cleanup now rejects unsafe recursive-delete targets (#3379)** — checkpoint and restore cleanup paths now require temporary directories to live under an explicit safe root and match the expected temporary-name prefix before recursive deletion is attempted. + +## 日本語 + +- **checkpoint の一時クリーンアップが安全でない recursive delete 対象を拒否するようになりました (#3379)** — checkpoint / restore の cleanup は、recursive delete の前に一時ディレクトリが明示された safe root 配下にあり、期待される一時名 prefix と一致することを確認します。 diff --git a/changelog.d/unreleased/3426.security.md b/changelog.d/unreleased/3426.security.md new file mode 100644 index 0000000000..587abc06d5 --- /dev/null +++ b/changelog.d/unreleased/3426.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3426 +affected: + - src/CodeIndex/Lsp/LspServer.cs + - tests/CodeIndex.Tests/LspServerTests.cs +--- + +## English + +- **LSP path resolution now applies explicit rootless trust rules (#3426)** — workspace containment now uses the shared path-casing helper, rootless LSP requests only trust relative indexed paths after a workspace folder is known, and position reads remain bounded if files grow while being read. + +## 日本語 + +- **LSP のパス解決が明示的な rootless trust rules を適用するようになりました (#3426)** — workspace containment は共有の path-casing helper を使い、rootless LSP request は workspace folder が判明した後だけ相対 indexed path を信頼し、position read は読み取り中にファイルが増えても上限内に収めます。 diff --git a/changelog.d/unreleased/3430.security.md b/changelog.d/unreleased/3430.security.md new file mode 100644 index 0000000000..41abd61014 --- /dev/null +++ b/changelog.d/unreleased/3430.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3430 +affected: + - src/CodeIndex/Cli/ActiveWorkspace.cs + - src/CodeIndex/Cli/WorkspaceCommandRunner.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +--- + +## English + +- **Active workspace state now validates root, db_path, and config-home inputs (#3430)** — malformed active workspace state must provide absolute root and database paths with the database under the active root, invalid config-home values are rejected before path composition, and warnings avoid echoing untrusted path values. + +## 日本語 + +- **active workspace state が root、db_path、config-home 入力を検証するようになりました (#3430)** — active workspace state は絶対 root / database path と active root 配下の database を必須にし、不正な config-home 値は path 合成前に拒否し、警告では未信頼の path 値を出力しません。 diff --git a/changelog.d/unreleased/3431.security.md b/changelog.d/unreleased/3431.security.md new file mode 100644 index 0000000000..63a8262144 --- /dev/null +++ b/changelog.d/unreleased/3431.security.md @@ -0,0 +1,16 @@ +--- +category: security +issues: + - 3431 +affected: + - src/CodeIndex/Cli/CdidxConfigFile.cs + - tests/CodeIndex.Tests/CdidxConfigFileTests.cs +--- + +## English + +- **cdidx config validation now bounds scalar, path, and numeric values (#3431)** — config loading enforces field-specific string limits, validates MCP rate-limit numbers before staging them into environment settings, and reports invalid path values with sanitized stable diagnostics. + +## 日本語 + +- **cdidx config validation が scalar、path、numeric value を上限検証するようになりました (#3431)** — config load は field-specific string limit を適用し、MCP rate-limit 数値を環境設定へ展開する前に検証し、不正な path 値は sanitize された安定診断で報告します。 diff --git a/changelog.d/unreleased/3514.security.md b/changelog.d/unreleased/3514.security.md new file mode 100644 index 0000000000..9441596eb6 --- /dev/null +++ b/changelog.d/unreleased/3514.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 3514 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- + +## English + +- **Database checkpoint operations now report hardened cleanup and restore diagnostics (#3514)** — checkpoint listing surfaces sanitized diagnostics, restore preserves the primary failure when rollback also fails, checkpoint restore rejects symlinked or non-regular payload files, and prune reports WAL cleanup warnings after a committed apply. + +## 日本語 + +- **database checkpoint 操作が hardened cleanup / restore 診断を返すようになりました (#3514)** — checkpoint list は sanitized diagnostics を返し、restore は rollback 失敗時も元の失敗を保持し、symlink や通常ファイルではない checkpoint payload を拒否し、prune apply 後の WAL cleanup 警告を報告します。 diff --git a/src/CodeIndex/Cli/ActiveWorkspace.cs b/src/CodeIndex/Cli/ActiveWorkspace.cs index 074d53c707..9b6e14b24b 100644 --- a/src/CodeIndex/Cli/ActiveWorkspace.cs +++ b/src/CodeIndex/Cli/ActiveWorkspace.cs @@ -20,11 +20,9 @@ internal static string StatePath { get { - var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); - var root = string.IsNullOrWhiteSpace(configHome) - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config") - : configHome; - return Path.Combine(root, "cdidx", "active.json"); + if (TryGetStatePath(out var path, out var reason)) + return path; + throw new InvalidOperationException($"Active workspace state path is invalid: {reason}."); } } @@ -34,7 +32,12 @@ internal static string StatePath if (!string.IsNullOrWhiteSpace(envPath)) return LoadFromEnvironment(envPath); - var path = StatePath; + if (!TryGetStatePath(out var path, out var statePathReason)) + { + WriteLoadWarning("config home", statePathReason); + return null; + } + if (!File.Exists(LongPath.EnsureWindowsPrefix(path))) return null; @@ -50,11 +53,15 @@ internal static string StatePath using var document = JsonDocument.Parse(text, StateJsonDocumentOptions); var root = document.RootElement; var name = ReadString(root, "name") ?? "default"; - var workspaceRoot = ReadString(root, "root") ?? Environment.CurrentDirectory; + var workspaceRoot = ReadString(root, "root"); var dbPath = ReadString(root, "db_path"); - if (string.IsNullOrWhiteSpace(dbPath)) + if (!TryNormalizeState(name, workspaceRoot, dbPath, out var state, out var stateReason)) + { + WriteLoadWarning("state file", stateReason); return null; - return new ActiveWorkspaceState(name, Path.GetFullPath(workspaceRoot), Path.GetFullPath(dbPath)); + } + + return state; } catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { @@ -65,9 +72,13 @@ internal static string StatePath internal static void Save(ActiveWorkspaceState state) { - DataDirectorySecurity.CreateSensitiveDirectory(Path.GetDirectoryName(StatePath)!); - var payload = new ActiveWorkspaceState(state.Name, Path.GetFullPath(state.Root), Path.GetFullPath(state.DbPath)); - DataDirectorySecurity.WritePrivateText(StatePath, JsonSerializer.Serialize(payload, ProgramRunner.CreateDefaultJsonOptions())); + if (!TryGetStatePath(out var statePath, out var statePathReason)) + throw new InvalidOperationException($"Active workspace state path is invalid: {statePathReason}."); + if (!TryNormalizeState(state.Name, state.Root, state.DbPath, out var payload, out var stateReason)) + throw new InvalidOperationException($"Active workspace state is invalid: {stateReason}."); + + DataDirectorySecurity.CreateSensitiveDirectory(Path.GetDirectoryName(statePath)!); + DataDirectorySecurity.WritePrivateText(statePath, JsonSerializer.Serialize(payload, ProgramRunner.CreateDefaultJsonOptions())); } private static string? ReadString(JsonElement element, string name) @@ -93,6 +104,139 @@ internal static void Save(ActiveWorkspaceState state) } } + private static bool TryGetStatePath(out string path, out string reason) + { + path = string.Empty; + reason = string.Empty; + var configHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + string root; + if (string.IsNullOrWhiteSpace(configHome)) + { + var profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(profile)) + { + reason = "user profile directory is unavailable"; + return false; + } + + root = Path.Combine(profile, ".config"); + } + else + { + if (configHome.Length > MaxEnvironmentPathChars) + { + reason = $"XDG_CONFIG_HOME exceeds {MaxEnvironmentPathChars} characters"; + return false; + } + + if (!IsFullyQualifiedPath(configHome)) + { + reason = "XDG_CONFIG_HOME must be an absolute path"; + return false; + } + + root = configHome; + } + + try + { + path = Path.Combine(NormalizeBoundaryPath(root), "cdidx", "active.json"); + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException) + { + reason = "XDG_CONFIG_HOME is invalid"; + return false; + } + } + + private static bool TryNormalizeState( + string? name, + string? root, + string? dbPath, + out ActiveWorkspaceState? state, + out string reason) + { + state = null; + reason = string.Empty; + if (string.IsNullOrWhiteSpace(root)) + { + reason = "`root` is required"; + return false; + } + + if (string.IsNullOrWhiteSpace(dbPath)) + { + reason = "`db_path` is required"; + return false; + } + + if (root.Length > MaxEnvironmentPathChars) + { + reason = $"`root` exceeds {MaxEnvironmentPathChars} characters"; + return false; + } + + if (dbPath.Length > MaxEnvironmentPathChars) + { + reason = $"`db_path` exceeds {MaxEnvironmentPathChars} characters"; + return false; + } + + if (!IsFullyQualifiedPath(root)) + { + reason = "`root` must be an absolute path"; + return false; + } + + if (!IsFullyQualifiedPath(dbPath)) + { + reason = "`db_path` must be an absolute path"; + return false; + } + + try + { + var normalizedRoot = NormalizeBoundaryPath(root); + var normalizedDbPath = Path.GetFullPath(dbPath); + if (PathCasing.PathsEqual(normalizedRoot, normalizedDbPath) + || !PathCasing.IsPathEqualOrParent(normalizedRoot, normalizedDbPath)) + { + reason = "`db_path` must be inside `root`"; + return false; + } + + state = new ActiveWorkspaceState(string.IsNullOrWhiteSpace(name) ? "default" : name, normalizedRoot, normalizedDbPath); + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or PathTooLongException) + { + reason = "state paths are invalid"; + return false; + } + } + + private static string NormalizeBoundaryPath(string path) + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (!string.IsNullOrEmpty(root) && string.Equals(fullPath, root, StringComparison.Ordinal)) + return fullPath; + return fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + private static bool IsFullyQualifiedPath(string path) + { + try + { + return Path.IsPathFullyQualified(path); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException) + { + return false; + } + } + private static string DescribeLoadFailure(Exception ex) => ex switch { JsonException => "invalid JSON", diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index fbf8af4eda..ae7338db91 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -33,6 +33,9 @@ internal static class CdidxConfigFile internal const int MaxConfigJsonDepth = 32; internal const int MaxConfigStringArrayItems = 128; internal const int MaxConfigStringArrayItemChars = 256; + internal const int MaxConfigScalarStringChars = 1024; + internal const int MaxConfigPathStringChars = 4096; + internal const int MaxConfigDurationStringChars = 256; private static readonly IReadOnlyList KnownTopLevelKeys = new[] { @@ -152,7 +155,7 @@ private static void AddTopLevelEnvironmentSettings(JsonElement root, string path { if (root.TryGetProperty("debug", out var debug)) { - if (!TryReadString(debug, "debug", path, out var value, out var err)) + if (!TryReadString(debug, "debug", path, MaxConfigScalarStringChars, out var value, out var err)) errors.Add(err!); else pending.Add(("CDIDX_DEBUG", value!)); @@ -184,7 +187,7 @@ private static void AddTopLevelEnvironmentSettings(JsonElement root, string path if (root.TryGetProperty("stale_after", out var staleAfter)) { - if (!TryReadString(staleAfter, "stale_after", path, out var value, out var err)) + if (!TryReadString(staleAfter, "stale_after", path, MaxConfigDurationStringChars, out var value, out var err)) errors.Add(err!); else pending.Add((QueryCommandRunner.StaleAfterEnvironmentVariable, value!)); @@ -195,14 +198,15 @@ private static void AddSuggestionEnvironmentSettings(JsonElement root, string pa { if (root.TryGetProperty("suggestion_dedup_threshold", out var suggestionDedupThreshold)) { - if (!TryReadNumberAsString(suggestionDedupThreshold, "suggestion_dedup_threshold", path, out var value, out var err)) + if (!TryReadFiniteDoubleAsString( + suggestionDedupThreshold, + "suggestion_dedup_threshold", + path, + maxInclusive: 1.0, + allowZero: true, + out var value, + out var err)) errors.Add(err!); - else if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var threshold) - || threshold < 0 - || threshold > 1) - { - errors.Add($"[cdidx] {path}: `suggestion_dedup_threshold` must be between 0 and 1."); - } else { pending.Add((SuggestionStore.DedupThresholdEnvironmentVariable, value!)); @@ -368,7 +372,14 @@ private static void AddMcpRateLimitEnvironmentSettings(JsonElement mcp, string p if (rateLimit.TryGetProperty("rps", out var rps)) { - if (!TryReadNumberAsString(rps, "mcp.rate_limit.rps", path, out var value, out var err)) + if (!TryReadFiniteDoubleAsString( + rps, + "mcp.rate_limit.rps", + path, + maxInclusive: RateLimiterOptions.MaxRefillTokensPerSecond, + allowZero: false, + out var value, + out var err)) errors.Add(err!); else pending.Add((RateLimiterOptions.RpsEnvVar, value!)); @@ -376,7 +387,14 @@ private static void AddMcpRateLimitEnvironmentSettings(JsonElement mcp, string p if (rateLimit.TryGetProperty("burst", out var burst)) { - if (!TryReadNumberAsString(burst, "mcp.rate_limit.burst", path, out var value, out var err)) + if (!TryReadFiniteDoubleAsString( + burst, + "mcp.rate_limit.burst", + path, + maxInclusive: RateLimiterOptions.MaxBurstCapacity, + allowZero: false, + out var value, + out var err)) errors.Add(err!); else pending.Add((RateLimiterOptions.BurstEnvVar, value!)); @@ -384,7 +402,14 @@ private static void AddMcpRateLimitEnvironmentSettings(JsonElement mcp, string p if (rateLimit.TryGetProperty("bucket_idle_seconds", out var bucketIdleSeconds)) { - if (!TryReadNumberAsString(bucketIdleSeconds, "mcp.rate_limit.bucket_idle_seconds", path, out var value, out var err)) + if (!TryReadFiniteDoubleAsString( + bucketIdleSeconds, + "mcp.rate_limit.bucket_idle_seconds", + path, + maxInclusive: TimeSpan.MaxValue.TotalSeconds, + allowZero: false, + out var value, + out var err)) errors.Add(err!); else pending.Add((RateLimiterOptions.BucketIdleSecondsEnvVar, value!)); @@ -539,7 +564,7 @@ private static void AddUnknownKeyDiagnostics( } } - private static bool TryReadString(JsonElement element, string key, string path, out string? value, out string? error) + private static bool TryReadString(JsonElement element, string key, string path, int maxChars, out string? value, out string? error) { value = null; error = null; @@ -554,6 +579,12 @@ private static bool TryReadString(JsonElement element, string key, string path, error = $"[cdidx] {path}: `{key}` must be a non-empty string."; return false; } + if (raw.Length > maxChars) + { + error = $"[cdidx] {path}: `{key}` must be <= {maxChars} characters."; + return false; + } + value = raw; return true; } @@ -562,7 +593,7 @@ private static bool TryReadWorkspaceOutputPath(JsonElement element, string key, { value = null; error = null; - if (!TryReadString(element, key, path, out var raw, out error)) + if (!TryReadString(element, key, path, MaxConfigPathStringChars, out var raw, out error)) return false; var workspaceRoot = ResolveConfigWorkspaceRoot(path); @@ -601,7 +632,7 @@ or NotSupportedException or PathTooLongException or UnauthorizedAccessException) { - pathError = $"[cdidx] {path}: `{key}` path is invalid: {ex.Message}"; + pathError = $"[cdidx] {path}: `{key}` path is invalid (invalid_path)."; return false; } } @@ -654,7 +685,14 @@ private static bool TryReadStringArray(JsonElement element, string key, string p return true; } - private static bool TryReadNumberAsString(JsonElement element, string key, string path, out string? value, out string? error) + private static bool TryReadFiniteDoubleAsString( + JsonElement element, + string key, + string path, + double maxInclusive, + bool allowZero, + out string? value, + out string? error) { value = null; error = null; @@ -663,7 +701,18 @@ private static bool TryReadNumberAsString(JsonElement element, string key, strin error = $"[cdidx] {path}: `{key}` must be a number."; return false; } - value = element.GetRawText(); + + if (!element.TryGetDouble(out var parsed) + || !double.IsFinite(parsed) + || (allowZero ? parsed < 0 : parsed <= 0) + || parsed > maxInclusive) + { + var minimum = allowZero ? "non-negative" : "positive"; + error = $"[cdidx] {path}: `{key}` must be a finite {minimum} number <= {maxInclusive.ToString(CultureInfo.InvariantCulture)}."; + return false; + } + + value = parsed.ToString(CultureInfo.InvariantCulture); return true; } diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index c97246b0da..3641c41587 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -279,7 +279,8 @@ private static int RunPrune(DbCommandOptions options, JsonSerializerOptions json result.OrphanSymbolReferences, result.OrphanReferenceLines, result.OrphanSymbols, - result.Total), + result.Total, + result.Warnings), jsonContext.DbPruneJsonResult)); } else @@ -290,6 +291,8 @@ private static int RunPrune(DbCommandOptions options, JsonSerializerOptions json Console.WriteLine($" orphan reference_lines : {result.OrphanReferenceLines:N0}"); Console.WriteLine($" orphan symbols : {result.OrphanSymbols:N0}"); Console.WriteLine($" total : {result.Total:N0}"); + foreach (var warning in result.Warnings) + Console.Error.WriteLine($"Warning [{warning.Code}]: {warning.Message}"); } return CommandExitCodes.Success; @@ -367,7 +370,8 @@ private static int RunListCheckpoints(DbCommandOptions options, JsonSerializerOp result.Entries, result.Truncated, CheckpointListEntryLimit, - CheckpointFileInspectLimit), + CheckpointFileInspectLimit, + result.Diagnostics), CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointListJsonResult)); } else @@ -385,6 +389,9 @@ private static int RunListCheckpoints(DbCommandOptions options, JsonSerializerOp foreach (var entry in result.Entries) Console.WriteLine($" {entry.Name} {entry.CreatedAtUtc} {entry.Bytes:N0} bytes{(entry.FilesTruncated ? " (files truncated)" : string.Empty)}"); } + + foreach (var diagnostic in result.Diagnostics) + Console.Error.WriteLine($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); } return CommandExitCodes.Success; @@ -575,10 +582,11 @@ private static (string Text, bool Truncated) TruncateDiagnosticText(string text, return (text[..limit] + " [truncated]", true); } - private static (int OrphanSymbolReferences, int OrphanReferenceLines, int OrphanSymbols, int Total) PruneOrphans(string dbPath, bool apply) + private static (int OrphanSymbolReferences, int OrphanReferenceLines, int OrphanSymbols, int Total, List Warnings) PruneOrphans(string dbPath, bool apply) { using var connection = OpenConnection(dbPath, writable: apply); using var transaction = apply ? connection.BeginTransaction() : null; + var warnings = new List(); var orphanSymbolReferences = Count(connection, transaction, @" SELECT COUNT(*) @@ -613,11 +621,13 @@ FROM reference_lines rl Execute(connection, transaction, "DELETE FROM symbols WHERE file_id NOT IN (SELECT id FROM files)"); transaction!.Commit(); Execute(connection, null, "PRAGMA optimize"); - RunWalCheckpointTruncate(connection); + var walWarning = RunWalCheckpointTruncate(connection); + if (walWarning is not null) + warnings.Add(walWarning); } var total = orphanSymbolReferences + orphanReferenceLines + orphanSymbols; - return (orphanSymbolReferences, orphanReferenceLines, orphanSymbols, total); + return (orphanSymbolReferences, orphanReferenceLines, orphanSymbols, total, warnings); } private static SqliteConnection OpenConnection(string dbPath, bool writable) @@ -646,7 +656,7 @@ private static void Execute(SqliteConnection connection, SqliteTransaction? tran cmd.ExecuteNonQuery(); } - private static void RunWalCheckpointTruncate(SqliteConnection connection) + private static DbDiagnosticJsonResult? RunWalCheckpointTruncate(SqliteConnection connection) { try { @@ -654,13 +664,30 @@ private static void RunWalCheckpointTruncate(SqliteConnection connection) cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; DbContext.WalCheckpointTruncateExecutedForTesting?.Invoke(connection.DataSource); cmd.ExecuteNonQuery(); + return null; } catch (Exception) { - // WAL truncation is opportunistic cleanup. Prune has already committed. + return new DbDiagnosticJsonResult( + "wal_checkpoint_truncate_failed", + "WAL checkpoint truncation failed after database prune committed.", + ConsoleUi.FormatBoundedValue(connection.DataSource)); } } + private static DbDiagnosticJsonResult CreateCheckpointDiagnostic(string code, string message, string path) + => new(code, message, ConsoleUi.FormatBoundedValue(path)); + + private static bool IsRecoverableFilesystemException(Exception ex) + => ex is IOException + or UnauthorizedAccessException + or ArgumentException + or NotSupportedException + or PathTooLongException; + + private static bool IsRecoverableRestoreException(Exception ex) + => IsRecoverableFilesystemException(ex) || ex is InvalidOperationException; + private static bool ValidateWritableFileDb(DbCommandOptions options, JsonSerializerOptions jsonOptions, string command, out string fullDbPath, out int exitCode) { exitCode = CommandExitCodes.Success; @@ -735,7 +762,11 @@ private static DbCheckpointOperationResult CreateCheckpoint(string fullDbPath, s } catch { - TryDeleteTemporaryDirectory(tempPath, "checkpoint temporary directory"); + TryDeleteTemporaryDirectory( + tempPath, + "checkpoint temporary directory", + root, + ".tmp-"); throw; } @@ -746,14 +777,17 @@ private static DbCheckpointOperationResult CreateCheckpoint(string fullDbPath, s private static DbCheckpointListReadResult ListCheckpoints(string fullDbPath) { var root = GetCheckpointRoot(fullDbPath); + var diagnostics = new List(); if (!Directory.Exists(root)) - return new DbCheckpointListReadResult([], Truncated: false); + return new DbCheckpointListReadResult([], Truncated: false, diagnostics); var dbFileName = Path.GetFileName(fullDbPath); var entries = new List(); var checkpointsTruncated = false; var directoriesInspected = 0; - foreach (var path in Directory.EnumerateDirectories(root)) + var directories = EnumerateCheckpointDirectories(root, diagnostics, CheckpointListEntryLimit + 1); + checkpointsTruncated |= directories.Truncated; + foreach (var path in directories.Items) { if (directoriesInspected >= CheckpointListEntryLimit) { @@ -767,18 +801,55 @@ private static DbCheckpointListReadResult ListCheckpoints(string fullDbPath) if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(path, dbFileName)))) continue; - var info = new DirectoryInfo(path); - var bytes = SumCheckpointBytes(path); + DirectoryInfo info; + DateTime createdAtUtc; + try + { + info = new DirectoryInfo(path); + createdAtUtc = info.CreationTimeUtc; + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_directory_stat_failed", "Unable to inspect checkpoint directory metadata.", path)); + checkpointsTruncated = true; + continue; + } + + var bytes = SumCheckpointBytes(path, diagnostics); entries.Add(new DbCheckpointListEntryJsonResult( info.Name, path, - info.CreationTimeUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + createdAtUtc.ToString("O", System.Globalization.CultureInfo.InvariantCulture), bytes.Bytes, bytes.Truncated)); } entries.Sort((left, right) => string.Compare(left.Name, right.Name, StringComparison.Ordinal)); - return new DbCheckpointListReadResult(entries, checkpointsTruncated || entries.Any(entry => entry.FilesTruncated)); + return new DbCheckpointListReadResult(entries, checkpointsTruncated || entries.Any(entry => entry.FilesTruncated), diagnostics); + } + + private static (List Items, bool Truncated) EnumerateCheckpointDirectories( + string root, + List diagnostics, + int limit) + { + var directories = new List(); + try + { + foreach (var directory in Directory.EnumerateDirectories(root)) + { + if (directories.Count >= limit) + return (directories, Truncated: true); + directories.Add(directory); + } + + return (directories, Truncated: false); + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_directory_enumeration_failed", "Unable to enumerate every checkpoint directory.", root)); + return (directories, Truncated: true); + } } private static (List Items, bool Truncated) EnumerateCheckpointFileNames(string checkpointPath) @@ -802,20 +873,54 @@ private static (List Items, bool Truncated) EnumerateCheckpointFileNames return (files, truncated); } - private static (long Bytes, bool Truncated) SumCheckpointBytes(string checkpointPath) + private static (long Bytes, bool Truncated) SumCheckpointBytes(string checkpointPath, List diagnostics) { long bytes = 0; var filesSeen = 0; - foreach (var file in Directory.EnumerateFiles(checkpointPath)) + var files = EnumerateCheckpointFiles(checkpointPath, diagnostics, CheckpointFileInspectLimit + 1); + foreach (var file in files.Items) { if (filesSeen >= CheckpointFileInspectLimit) return (bytes, Truncated: true); - bytes += new FileInfo(file).Length; + try + { + bytes += new FileInfo(file).Length; + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_stat_failed", "Unable to inspect every checkpoint file.", file)); + return (bytes, Truncated: true); + } + filesSeen++; } - return (bytes, Truncated: false); + return (bytes, files.Truncated); + } + + private static (List Items, bool Truncated) EnumerateCheckpointFiles( + string checkpointPath, + List diagnostics, + int limit) + { + var files = new List(); + try + { + foreach (var file in Directory.EnumerateFiles(checkpointPath)) + { + if (files.Count >= limit) + return (files, Truncated: true); + files.Add(file); + } + + return (files, Truncated: false); + } + catch (Exception ex) when (IsRecoverableFilesystemException(ex)) + { + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_enumeration_failed", "Unable to enumerate every checkpoint file.", checkpointPath)); + return (files, Truncated: true); + } } private static string RestoreCheckpoint(string fullDbPath, string name, string checkpointPath) @@ -851,12 +956,24 @@ private static string RestoreCheckpoint(string fullDbPath, string name, string c } catch { - RestoreBackedUpFiles(fullDbPath, backupPath); + try + { + RestoreBackedUpFiles(fullDbPath, backupPath); + } + catch (Exception rollbackEx) when (IsRecoverableRestoreException(rollbackEx)) + { + Console.Error.WriteLine($"Warning: failed to roll back database restore from backup {ConsoleUi.FormatBoundedValue(backupPath)} ({CommandErrorWriter.FormatSanitizedException(rollbackEx)})."); + } + throw; } finally { - TryDeleteTemporaryDirectory(restoreTempPath, "restore temporary directory"); + TryDeleteTemporaryDirectory( + restoreTempPath, + "restore temporary directory", + Path.GetDirectoryName(fullDbPath) ?? Path.GetPathRoot(fullDbPath) ?? Path.GetFullPath("."), + Path.GetFileName(fullDbPath) + ".restore-tmp-"); } return backupPath; @@ -897,22 +1014,35 @@ private static string GetCheckpointPath(string fullDbPath, string name) private static void CopyIfExists(string source, string destination, bool privateDestination = false) { - if (File.Exists(LongPath.EnsureWindowsPrefix(source))) - { - File.Copy(LongPath.EnsureWindowsPrefix(source), LongPath.EnsureWindowsPrefix(destination), overwrite: false); - if (privateDestination) - DataDirectorySecurity.ApplyPrivateFileMode(destination); - } + if (!TryGetRegularExistingFile(source, out var normalizedSource)) + return; + + File.Copy(normalizedSource, LongPath.EnsureWindowsPrefix(destination), overwrite: false); + if (privateDestination) + DataDirectorySecurity.ApplyPrivateFileMode(destination); } private static void MoveIfExists(string source, string destination, bool privateDestination = false) { - if (File.Exists(LongPath.EnsureWindowsPrefix(source))) - { - File.Move(LongPath.EnsureWindowsPrefix(source), LongPath.EnsureWindowsPrefix(destination)); - if (privateDestination) - DataDirectorySecurity.ApplyPrivateFileMode(destination); - } + if (!TryGetRegularExistingFile(source, out var normalizedSource)) + return; + + File.Move(normalizedSource, LongPath.EnsureWindowsPrefix(destination)); + if (privateDestination) + DataDirectorySecurity.ApplyPrivateFileMode(destination); + } + + private static bool TryGetRegularExistingFile(string path, out string normalizedPath) + { + normalizedPath = LongPath.EnsureWindowsPrefix(path); + if (!File.Exists(normalizedPath)) + return false; + + var attributes = File.GetAttributes(normalizedPath); + if ((attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + throw new InvalidOperationException($"checkpoint file is not a regular file: {ConsoleUi.FormatBoundedValue(path)}"); + + return true; } private static void RestoreBackedUpFiles(string fullDbPath, string backupPath) @@ -934,17 +1064,23 @@ private static void DeleteIfExists(string path) File.Delete(LongPath.EnsureWindowsPrefix(path)); } - private static void TryDeleteTemporaryDirectory(string path, string cleanupDescription) + internal static void TryDeleteTemporaryDirectory(string path, string cleanupDescription, string safeRoot, string expectedNamePrefix) { try { - if (!Directory.Exists(path)) + if (!TryValidateTemporaryDirectoryCleanupTarget(path, safeRoot, expectedNamePrefix, out var fullPath, out var validationFailure)) + { + Console.Error.WriteLine($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); + return; + } + + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) return; if (DeleteTemporaryDirectoryForTesting != null) - DeleteTemporaryDirectoryForTesting(path); + DeleteTemporaryDirectoryForTesting(fullPath); else - Directory.Delete(path, recursive: true); + Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) { @@ -952,6 +1088,50 @@ private static void TryDeleteTemporaryDirectory(string path, string cleanupDescr } } + private static bool TryValidateTemporaryDirectoryCleanupTarget( + string path, + string safeRoot, + string expectedNamePrefix, + out string fullPath, + out string failureReason) + { + fullPath = string.Empty; + failureReason = string.Empty; + try + { + fullPath = NormalizeBoundaryPath(Path.GetFullPath(path)); + var normalizedRoot = NormalizeBoundaryPath(Path.GetFullPath(safeRoot)); + if (string.Equals(fullPath, normalizedRoot, PathCasing.ComparisonFor(normalizedRoot)) + || !PathCasing.IsPathEqualOrParent(normalizedRoot, fullPath)) + { + failureReason = "target is outside the expected cleanup root"; + return false; + } + + if (!Path.GetFileName(fullPath).StartsWith(expectedNamePrefix, StringComparison.Ordinal)) + { + failureReason = "target name does not match the expected temporary-directory prefix"; + return false; + } + + return true; + } + catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException or PathTooLongException) + { + failureReason = "target path is invalid"; + return false; + } + } + + private static string NormalizeBoundaryPath(string path) + { + var fullPath = Path.GetFullPath(path); + var root = Path.GetPathRoot(fullPath); + if (!string.IsNullOrEmpty(root) && string.Equals(fullPath, root, StringComparison.Ordinal)) + return fullPath; + return fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + internal static DbCommandOptions ParseArgs(string[] args) { var dbPath = Path.Combine(".cdidx", "codeindex.db"); @@ -1082,7 +1262,7 @@ internal sealed class DbCommandOptions internal sealed record DbCheckpointOperationResult(string Name, string CheckpointPath, List Files, bool FilesTruncated); -internal sealed record DbCheckpointListReadResult(List Entries, bool Truncated); +internal sealed record DbCheckpointListReadResult(List Entries, bool Truncated, List Diagnostics); internal sealed record DbIntegrityCheckReadResult(List Rows, bool RowsTruncated, bool TextTruncated) { diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index f3c550643a..9a0b08c593 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -75,7 +75,8 @@ internal sealed record DbCheckpointListJsonResult( [property: JsonPropertyName("checkpoints")] List Checkpoints, [property: JsonPropertyName("truncated")] bool Truncated = false, [property: JsonPropertyName("checkpoint_limit")] int CheckpointLimit = 0, - [property: JsonPropertyName("file_limit")] int FileLimit = 0); + [property: JsonPropertyName("file_limit")] int FileLimit = 0, + [property: JsonPropertyName("diagnostics")] List? Diagnostics = null); internal sealed record DbCheckpointListEntryJsonResult( [property: JsonPropertyName("name")] string Name, @@ -118,7 +119,13 @@ internal sealed record DbPruneJsonResult( [property: JsonPropertyName("orphan_symbol_references")] int OrphanSymbolReferences, [property: JsonPropertyName("orphan_reference_lines")] int OrphanReferenceLines, [property: JsonPropertyName("orphan_symbols")] int OrphanSymbols, - [property: JsonPropertyName("total")] int Total); + [property: JsonPropertyName("total")] int Total, + [property: JsonPropertyName("warnings")] List? Warnings = null); + +internal sealed record DbDiagnosticJsonResult( + [property: JsonPropertyName("code")] string Code, + [property: JsonPropertyName("message")] string Message, + [property: JsonPropertyName("path")] string? Path = null); internal sealed record DiffSummaryJsonResult( [property: JsonPropertyName("left_file_count")] long LeftFileCount, @@ -419,6 +426,7 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(DbCheckpointJsonResult))] [JsonSerializable(typeof(DbCheckpointListEntryJsonResult))] [JsonSerializable(typeof(DbCheckpointListJsonResult))] +[JsonSerializable(typeof(DbDiagnosticJsonResult))] [JsonSerializable(typeof(DbIntegrityCheckJsonResult))] [JsonSerializable(typeof(DbPruneJsonResult))] [JsonSerializable(typeof(DbRestoreJsonResult))] diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index bdad8798e2..e2bc884031 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -91,10 +91,26 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio if (member is { Exists: false }) return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member is missing on disk.", CommandExitCodes.UsageError, "create the missing member directory or run `cdidx workspace list` and choose an existing member."); - var root = member?.Path ?? Environment.CurrentDirectory; + var root = Environment.CurrentDirectory; + if (member != null) + { + var manifestRoot = manifest ?? throw new InvalidOperationException("workspace manifest was not found."); + root = string.Equals(manifestRoot.IndexStrategy, "single", StringComparison.OrdinalIgnoreCase) + ? manifestRoot.Root + : member.Path; + } + var dbPath = member?.DbPath ?? DbPathResolver.ResolveForIndex(root, explicitDbPath: null); var state = new ActiveWorkspaceState(name, root, dbPath); - ActiveWorkspace.Save(state); + try + { + ActiveWorkspace.Save(state); + } + catch (InvalidOperationException ex) + { + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, ex.Message, CommandExitCodes.UsageError, "set XDG_CONFIG_HOME to an absolute writable directory or choose a workspace whose database is inside its root."); + } + if (json) Console.WriteLine(JsonSerializer.Serialize(new ActiveWorkspaceJsonResult(state, ActiveWorkspace.StatePath), jsonOptions)); else diff --git a/src/CodeIndex/Lsp/LspServer.cs b/src/CodeIndex/Lsp/LspServer.cs index 00eed434ad..c8ac439d4d 100644 --- a/src/CodeIndex/Lsp/LspServer.cs +++ b/src/CodeIndex/Lsp/LspServer.cs @@ -678,6 +678,12 @@ private static bool TryReadPositionLine(string path, int targetLine, out string while (true) { var next = reader.Read(); + if (stream.Position > MaxPositionDocumentBytes) + { + failureReason = FailurePositionFileTooLarge; + return false; + } + if (next < 0) { if (currentLine == targetLine && currentLineLength <= MaxPositionLineChars && builder != null) @@ -694,7 +700,14 @@ private static bool TryReadPositionLine(string path, int targetLine, out string if (c == '\r' || c == '\n') { if (c == '\r' && reader.Peek() == '\n') + { reader.Read(); + if (stream.Position > MaxPositionDocumentBytes) + { + failureReason = FailurePositionFileTooLarge; + return false; + } + } if (currentLine == targetLine) { @@ -770,6 +783,9 @@ private bool MatchesDocumentPath(string indexedPath, string documentPath, string if (string.Equals(indexedPath, documentPath, StringComparison.Ordinal)) return true; + if (_projectRoot == null && workspaceRoot == null) + return false; + var normalizedDocument = documentPath.Replace('\\', '/'); return normalizedDocument.EndsWith("/" + normalizedIndexed, StringComparison.Ordinal); } @@ -899,7 +915,15 @@ private static bool TryGetRelativePath(string root, string resolvedPath, out str relativePath = null; try { - var relative = Path.GetRelativePath(Path.GetFullPath(root), resolvedPath); + var normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + var normalizedPath = Path.GetFullPath(resolvedPath); + if (PathCasing.PathsEqual(normalizedRoot, normalizedPath) + || !PathCasing.IsPathEqualOrParent(normalizedRoot, normalizedPath)) + { + return false; + } + + var relative = Path.GetRelativePath(normalizedRoot, normalizedPath); if (relative == "." || relative == ".." || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) diff --git a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs index 8049e966ed..eef9940827 100644 --- a/tests/CodeIndex.Tests/CdidxConfigFileTests.cs +++ b/tests/CodeIndex.Tests/CdidxConfigFileTests.cs @@ -470,6 +470,75 @@ public void LoadAndApply_StringArrayItemAboveMaximumLength_ReturnsError() finally { TestProjectHelper.DeleteDirectory(dir); } } + [Fact] + public void LoadAndApply_ScalarStringAboveMaximumLength_ReturnsError_Issue3431() + { + var dir = CreateTempDir(); + try + { + var value = new string('x', CdidxConfigFile.MaxConfigScalarStringChars + 1); + File.WriteAllText( + Path.Combine(dir, ".cdidxrc.json"), + $$"""{ "debug": "{{value}}" }"""); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains("debug", result.Error); + Assert.Contains($"<= {CdidxConfigFile.MaxConfigScalarStringChars} characters", result.Error); + Assert.Empty(env.Writes); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + + [Fact] + public void LoadAndApply_PathStringAboveMaximumLength_ReturnsError_Issue3431() + { + var dir = CreateTempDir(); + const string Sentinel = "PATH_LENGTH_SENTINEL_3431"; + try + { + var value = new string('x', CdidxConfigFile.MaxConfigPathStringChars + 1) + Sentinel; + File.WriteAllText( + Path.Combine(dir, ".cdidxrc.json"), + $$"""{ "metrics_path": "{{value}}" }"""); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains("metrics_path", result.Error); + Assert.Contains($"<= {CdidxConfigFile.MaxConfigPathStringChars} characters", result.Error); + Assert.DoesNotContain(Sentinel, result.Error); + Assert.Empty(env.Writes); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + + [Fact] + public void LoadAndApply_InvalidOutputPathUsesSanitizedDiagnostic_Issue3431() + { + var dir = CreateTempDir(); + const string Sentinel = "PATH_EXCEPTION_SENTINEL_3431"; + try + { + File.WriteAllText( + Path.Combine(dir, ".cdidxrc.json"), + $$"""{ "metrics_path": "{{Sentinel}}\u0000.txt" }"""); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains("metrics_path", result.Error); + Assert.Contains("invalid_path", result.Error); + Assert.DoesNotContain(Sentinel, result.Error); + Assert.Empty(env.Writes); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + [Fact] public void LoadAndApply_WrongType_ReturnsError() { @@ -488,6 +557,59 @@ public void LoadAndApply_WrongType_ReturnsError() finally { TestProjectHelper.DeleteDirectory(dir); } } + [Theory] + [InlineData("""{ "mcp": { "rate_limit": { "rps": 0 } } }""", "mcp.rate_limit.rps")] + [InlineData("""{ "mcp": { "rate_limit": { "bucket_idle_seconds": 1e9999 } } }""", "mcp.rate_limit.bucket_idle_seconds")] + public void LoadAndApply_InvalidMcpRateLimitNumber_ReturnsError_Issue3431(string json, string expectedKey) + { + var dir = CreateTempDir(); + try + { + File.WriteAllText(Path.Combine(dir, ".cdidxrc.json"), json); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains(expectedKey, result.Error); + Assert.Contains("finite", result.Error); + Assert.Empty(env.Writes); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + + [Fact] + public void LoadAndApply_McpRateLimitAboveMaximum_ReturnsError_Issue3431() + { + var dir = CreateTempDir(); + try + { + var rps = RateLimiterOptions.MaxRefillTokensPerSecond + 1; + var burst = RateLimiterOptions.MaxBurstCapacity + 1; + File.WriteAllText(Path.Combine(dir, ".cdidxrc.json"), $$""" + { + "mcp": { + "rate_limit": { + "rps": {{rps.ToString(System.Globalization.CultureInfo.InvariantCulture)}}, + "burst": {{burst.ToString(System.Globalization.CultureInfo.InvariantCulture)}} + } + } + } + """); + + var env = new TestEnvironment(); + var result = CdidxConfigFile.LoadAndApply(dir, env.Read, env.Write); + + Assert.True(result.Failed); + Assert.Contains("mcp.rate_limit.rps", result.Error); + Assert.Contains(RateLimiterOptions.MaxRefillTokensPerSecond.ToString(System.Globalization.CultureInfo.InvariantCulture), result.Error); + Assert.Contains("mcp.rate_limit.burst", result.Error); + Assert.Contains(RateLimiterOptions.MaxBurstCapacity.ToString(System.Globalization.CultureInfo.InvariantCulture), result.Error); + Assert.Empty(env.Writes); + } + finally { TestProjectHelper.DeleteDirectory(dir); } + } + [Fact] public void LoadAndApply_InvalidSuggestionDedupThreshold_ReturnsError() { diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 4d4202ee5f..a08b7904e6 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -405,6 +405,35 @@ public void Run_Prune_DryRunCountsAndApplyDeletesOrphans() } } + [Fact] + public void Run_PruneApply_JsonReportsWalCheckpointWarning_Issue3514() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_db_prune_wal_warning_{Guid.NewGuid():N}.db"); + try + { + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SeedOrphans(dbPath); + SqliteConnection.ClearAllPools(); + DbContext.WalCheckpointTruncateExecutedForTesting = _ => throw new IOException("simulated wal cleanup failure"); + + var (exitCode, json) = RunAndCaptureJson(["prune", "--apply", "--db", dbPath, "--json"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + var warnings = json.GetProperty("warnings"); + var warning = Assert.Single(warnings.EnumerateArray()); + Assert.Equal("wal_checkpoint_truncate_failed", warning.GetProperty("code").GetString()); + Assert.Contains("WAL checkpoint truncation failed", warning.GetProperty("message").GetString()); + } + finally + { + DbContext.WalCheckpointTruncateExecutedForTesting = null; + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void Run_CheckpointAndRestore_RestoresDatabaseBytes() { @@ -541,6 +570,40 @@ public void Run_CheckpointTempCleanupFailurePreservesOriginalFailure_Issue3029() } } + [Fact] + public void TryDeleteTemporaryDirectory_RejectsTargetOutsideSafeRoot_Issue3379() + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_cleanup_safe_root_{Guid.NewGuid():N}"); + var safeRoot = Path.Combine(root, "safe"); + var outsideRoot = Path.Combine(root, "outside"); + var outsideTarget = Path.Combine(outsideRoot, ".tmp-malformed"); + try + { + Directory.CreateDirectory(safeRoot); + Directory.CreateDirectory(outsideTarget); + File.WriteAllText(Path.Combine(outsideTarget, "sentinel.txt"), "keep"); + + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + DbCommandRunner.TryDeleteTemporaryDirectory( + outsideTarget, + "test temporary directory", + safeRoot, + ".tmp-"); + return 0; + }); + + Assert.True(Directory.Exists(outsideTarget)); + Assert.Contains("skipped deleting test temporary directory", stderr); + Assert.Contains("outside the expected cleanup root", stderr); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_CheckpointsList_JsonIncludesCreatedCheckpoint() { @@ -680,6 +743,81 @@ public void Run_RestoreFailureAfterBackup_RestoresOriginalDatabase() } } + [Fact] + public void Run_RestoreRollbackFailurePreservesPrimaryFailure_Issue3514() + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_checkpoint_rollback_fail_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(root, "codeindex.db"); + Directory.CreateDirectory(root); + try + { + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SqliteConnection.ClearAllPools(); + + var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); + Assert.Equal(CommandExitCodes.Success, checkpointExit); + + File.WriteAllText(dbPath, "changed"); + DbCommandRunner.RestoreFailureAfterBackupForTesting = () => + { + Directory.CreateDirectory(dbPath); + throw new IOException("primary restore failure"); + }; + + var (restoreExit, _, stderr) = RunAndCaptureStreams(["restore", "saved", "--db", dbPath]); + + Assert.Equal(CommandExitCodes.DatabaseError, restoreExit); + Assert.Contains("primary restore failure", stderr); + Assert.Contains("failed to roll back database restore", stderr); + } + finally + { + DbCommandRunner.RestoreFailureAfterBackupForTesting = null; + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void Run_RestoreRejectsSymlinkedCheckpointPayload_Issue3514() + { + if (OperatingSystem.IsWindows()) + return; + + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_checkpoint_symlink_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(root, "codeindex.db"); + Directory.CreateDirectory(root); + try + { + using (var db = new DbContext(dbPath)) + db.InitializeSchema(); + SqliteConnection.ClearAllPools(); + var originalBytes = File.ReadAllBytes(dbPath); + var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); + Assert.Equal(CommandExitCodes.Success, checkpointExit); + + var checkpointDbPath = Path.Combine(dbPath + ".checkpoints", "saved", "codeindex.db"); + File.Delete(checkpointDbPath); + var targetPath = Path.Combine(root, "payload-target.db"); + File.WriteAllText(targetPath, "not the checkpoint"); + File.CreateSymbolicLink(checkpointDbPath, targetPath); + + var (restoreExit, _, stderr) = RunAndCaptureStreams(["restore", "saved", "--db", dbPath]); + + Assert.Equal(CommandExitCodes.DatabaseError, restoreExit); + Assert.Contains("not a regular file", stderr); + Assert.Equal(originalBytes, File.ReadAllBytes(dbPath)); + } + finally + { + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_RestoreTemporaryNamesIncludeCollisionResistantSuffix_Issue3031() { diff --git a/tests/CodeIndex.Tests/LspServerTests.cs b/tests/CodeIndex.Tests/LspServerTests.cs index e964257d16..ecd9f24455 100644 --- a/tests/CodeIndex.Tests/LspServerTests.cs +++ b/tests/CodeIndex.Tests/LspServerTests.cs @@ -1718,6 +1718,75 @@ public void HandleMessage_Definition_BasenameFallbackHonorsCandidateCap_Issue313 } } + [Fact] + public void HandleMessage_Definition_RootlessRejectsRelativeIndexedPathWithoutWorkspace_Issue3426() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_rootless_relative"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "src", "app.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + var source = "class Target { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions()); + var request = CreateDefinitionRequest( + sourcePath, + 3426, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + var activities = new List(); + using var listener = CaptureCodeIndexActivities(activities); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.Empty(response!["result"]!.AsArray()); + var requestActivity = Assert.Single(activities.Where(activity => activity.OperationName == "lsp.request")); + var failureEvent = Assert.Single(requestActivity.Events.Where(activityEvent => activityEvent.Name == "lsp.lookup_failed")); + var tags = failureEvent.Tags.ToDictionary(tag => tag.Key, tag => tag.Value?.ToString(), StringComparer.Ordinal); + Assert.Equal("file_not_indexed", tags["lsp.lookup.failure_reason"]); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void HandleMessage_Definition_RootlessUsesWorkspaceFolderForRelativeIndexedPath_Issue3426() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_lsp_definition_rootless_workspace"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var sourcePath = Path.Combine(projectRoot, "src", "app.cs"); + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + var source = "class Target { void Needle() { } void Call() { Needle(); } }\n"; + File.WriteAllText(sourcePath, source); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", source); + using var db = new DbContext(dbPath); + using var server = new LspServer(new DbReader(db), "1.2.3", ProgramRunner.CreateDefaultJsonOptions()); + Assert.NotNull(server.HandleMessage(CreateInitializeRequestWithWorkspaceFolder(projectRoot, 34260))); + var request = CreateDefinitionRequest( + sourcePath, + 34261, + 0, + source.IndexOf("Needle();", StringComparison.Ordinal)); + + var response = server.HandleMessage(request); + + Assert.NotNull(response); + Assert.NotEmpty(response!["result"]!.AsArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + private static string CreateDefinitionRequest(string sourcePath, int id, int line, int character) => CreatePositionRequest("textDocument/definition", sourcePath, id, line, character); diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 7a7d6ca74f..5e55eac410 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -538,6 +538,155 @@ public void MalformedActiveWorkspaceState_DoesNotOverrideQueryResolution() } } + [Fact] + public void ActiveWorkspaceState_MissingRoot_DoesNotOverrideQueryResolution_Issue3430() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_missing_root_project"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_active_workspace_missing_root_config"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + Directory.CreateDirectory(Path.GetDirectoryName(ActiveWorkspace.StatePath)!); + File.WriteAllText(ActiveWorkspace.StatePath, $$""" + { + "name": "active", + "db_path": {{JsonSerializer.Serialize(Path.Combine(projectRoot, ".cdidx", "codeindex.db"))}} + } + """); + + DbPathResolution? query = null; + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + return 0; + }); + + Assert.NotNull(query); + Assert.Contains("Ignoring active workspace state", stderr); + Assert.Contains("`root` is required", stderr); + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query!.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(configHome); + } + } + + [Fact] + public void ActiveWorkspaceState_MissingDbPath_DoesNotOverrideQueryResolution_Issue3430() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_missing_db_project"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_active_workspace_missing_db_config"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + Directory.CreateDirectory(Path.GetDirectoryName(ActiveWorkspace.StatePath)!); + File.WriteAllText(ActiveWorkspace.StatePath, $$""" + { + "name": "active", + "root": {{JsonSerializer.Serialize(projectRoot)}} + } + """); + + DbPathResolution? query = null; + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + return 0; + }); + + Assert.NotNull(query); + Assert.Contains("Ignoring active workspace state", stderr); + Assert.Contains("`db_path` is required", stderr); + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query!.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(configHome); + } + } + + [Fact] + public void ActiveWorkspaceState_DbPathOutsideRoot_DoesNotOverrideQueryResolution_Issue3430() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_outside_db_project"); + var outsideRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_outside_db"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_active_workspace_outside_db_config"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + Directory.CreateDirectory(Path.GetDirectoryName(ActiveWorkspace.StatePath)!); + File.WriteAllText(ActiveWorkspace.StatePath, $$""" + { + "name": "active", + "root": {{JsonSerializer.Serialize(projectRoot)}}, + "db_path": {{JsonSerializer.Serialize(Path.Combine(outsideRoot, ".cdidx", "codeindex.db"))}} + } + """); + + DbPathResolution? query = null; + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + return 0; + }); + + Assert.NotNull(query); + Assert.Contains("Ignoring active workspace state", stderr); + Assert.Contains("`db_path` must be inside `root`", stderr); + Assert.DoesNotContain(outsideRoot, stderr); + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query!.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(outsideRoot); + TestProjectHelper.DeleteDirectory(configHome); + } + } + + [Fact] + public void ActiveWorkspaceState_RelativeConfigHome_DoesNotOverrideQueryResolution_Issue3430() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_relative_config_project"); + const string RelativeConfigHome = "relative_config_HOME_SENTINEL_3430"; + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", RelativeConfigHome); + + DbPathResolution? query = null; + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + return 0; + }); + + Assert.NotNull(query); + Assert.Contains("Ignoring active workspace config home", stderr); + Assert.Contains("XDG_CONFIG_HOME must be an absolute path", stderr); + Assert.DoesNotContain(RelativeConfigHome, stderr); + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query!.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void DeeplyNestedActiveWorkspaceState_DoesNotOverrideQueryResolution_Issue3036() { @@ -808,6 +957,82 @@ public void WorkspaceUse_RejectsNamedWorkspaceWithoutManifest() } } + [Fact] + public void WorkspaceUse_RelativeConfigHomeReturnsSafeError_Issue3430() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_relative_config"); + const string RelativeConfigHome = "relative_config_HOME_SENTINEL_USE_3430"; + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", RelativeConfigHome); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, _, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["use", "default"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("XDG_CONFIG_HOME must be an absolute path", stderr); + Assert.DoesNotContain(RelativeConfigHome, stderr); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + + [Fact] + public void WorkspaceUse_SingleStrategyStoresManifestRoot_Issue3430() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_single_strategy"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_single_strategy_config"); + try + { + Directory.CreateDirectory(Path.Combine(root, "src", "A")); + File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """ + { + "members": ["src/A"], + "index_strategy": "single" + } + """); + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, _, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["use", "A"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + var state = ActiveWorkspace.Load(); + Assert.NotNull(state); + var expectedRoot = Path.GetFullPath(Environment.CurrentDirectory); + Assert.Equal(expectedRoot, state.Root); + Assert.Equal(Path.GetFullPath(Path.Combine(expectedRoot, ".cdidx", "codeindex.db")), state.DbPath); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + TestProjectHelper.DeleteDirectory(configHome); + } + } + [Fact] public void WorkspaceUseDefault_DoesNotSelectFirstManifestMember() {