From ad3c693cd796f4edddfebe0d183e8152dc5bf40d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 01:08:50 +0900 Subject: [PATCH 01/10] Fix #3810 sanitize read migration warnings --- changelog.d/unreleased/3810.fixed.md | 16 +++++++++++ src/CodeIndex/Database/DbContext.cs | 28 ++++++------------- .../LegacySchemaMigrationTests.cs | 9 ++++++ 3 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 changelog.d/unreleased/3810.fixed.md diff --git a/changelog.d/unreleased/3810.fixed.md b/changelog.d/unreleased/3810.fixed.md new file mode 100644 index 0000000000..fabb43ab22 --- /dev/null +++ b/changelog.d/unreleased/3810.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3810 +affected: + - src/CodeIndex/Database/DbContext.cs + - tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs +--- + +## English + +- **Read-migration warnings no longer expose local database paths (#3810)** — partial read-migration failures now clear active migration state on every exit path and bound/redact SQLite warning text while keeping writable-storage remediation actionable. + +## 日本語 + +- **read migration の警告がローカル DB パスを露出しないようになりました (#3810)** — 部分的な read migration 失敗時も active migration 状態を必ず解除し、SQLite 警告文を上限付きで赤字化しつつ writable storage での復旧案内を維持します。 diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index a8a81d8863..d64440979e 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -1,4 +1,5 @@ using CodeIndex.Cli; +using CodeIndex.Diagnostics; using CodeIndex.Indexer; using CodeIndex.Models; using Microsoft.Data.Sqlite; @@ -23,6 +24,7 @@ public class DbContext : IDisposable public const int DefaultWalAutocheckpointPages = 1000; public const string DefaultSynchronousMode = "NORMAL"; public const string SymbolExtractorVersionMetaPrefix = "symbol_extractor_version_"; + private const int MigrationDiagnosticTextLimit = 240; private static readonly string[] RequiredCodeIndexTables = [ @@ -2380,6 +2382,7 @@ public void TryMigrateForRead() } finally { + _activeMigrationTransaction = null; // Migration may have added columns or indexes the schema cache had already // resolved as missing; drop the cache so the next DbReader sees the new shape. // マイグレーションで列・index が追加された可能性があるためキャッシュを破棄する。 @@ -2451,7 +2454,7 @@ private void RecordMigrationFailure(string description, SqliteException exceptio var failure = new DbMigrationFailure( description, exception.SqliteErrorCode, - exception.Message, + FormatMigrationSqliteMessage(exception), BuildMigrationSuggestedAction(exception.SqliteErrorCode)); LastMigrationFailure = failure; EmitMigrationFailureWarning(failure); @@ -2602,10 +2605,7 @@ private string BuildMigrationSuggestedAction(int sqliteErrorCode) // 8/10/14 は restricted mount 系の典型シグネチャ。書き込み可能な場所での再実行を案内する。 if (sqliteErrorCode is 8 or 10 or 14) { - var dbDir = TryGetDbDirectoryForSuggestion(); - return dbDir is null - ? "Re-run cdidx on writable storage, or grant write access to the database directory (e.g. chmod +w on the .cdidx directory), so the schema migration can complete." - : $"Re-run cdidx on writable storage, or grant write access to '{dbDir}' (e.g. chmod +w '{dbDir}'), so the schema migration can complete."; + return "Re-run cdidx on writable storage, or grant write access to (for example, chmod +w ), so the schema migration can complete."; } // Unknown SQLite codes — surface the code itself and point at integrity check. @@ -2613,20 +2613,10 @@ private string BuildMigrationSuggestedAction(int sqliteErrorCode) return $"Inspect the database with 'sqlite3 \"PRAGMA integrity_check\"' (SQLite error code {sqliteErrorCode})."; } - private string? TryGetDbDirectoryForSuggestion() - { - try - { - var dataSource = _connection.DataSource; - if (string.IsNullOrEmpty(dataSource)) return null; - var fullPath = Path.GetFullPath(dataSource); - return Path.GetDirectoryName(fullPath); - } - catch - { - return null; - } - } + private static string FormatMigrationSqliteMessage(SqliteException exception) + => DiagnosticRedactor.BoundDiagnosticText( + DiagnosticRedactor.RedactSensitiveText(exception.Message, redactPaths: true), + MigrationDiagnosticTextLimit); private static void EmitMigrationFailureWarning(DbMigrationFailure failure) { diff --git a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs index 564c623b63..e260b2c3f8 100644 --- a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs +++ b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs @@ -397,6 +397,9 @@ public void TryMigrateForRead_PartialDdlFailure_RecordsStepAndEmitsActionableWar Assert.Equal(8, db.LastMigrationFailure.SqliteErrorCode); Assert.Contains("writable storage", db.LastMigrationFailure.SuggestedAction, StringComparison.OrdinalIgnoreCase); Assert.Contains("chmod", db.LastMigrationFailure.SuggestedAction, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(_dbDir, db.LastMigrationFailure.SuggestedAction, StringComparison.Ordinal); + Assert.DoesNotContain(_dbDir, db.LastMigrationFailure.SqliteMessage, StringComparison.Ordinal); + Assert.Null(GetActiveMigrationTransaction(db)); // Single stderr line so the diagnostic is hard to miss but does not flood logs. // 1 行の stderr 警告。 @@ -405,6 +408,7 @@ public void TryMigrateForRead_PartialDdlFailure_RecordsStepAndEmitsActionableWar Assert.Contains("SQLite error 8", stderr, StringComparison.Ordinal); Assert.Contains("no such column", stderr, StringComparison.OrdinalIgnoreCase); Assert.Contains("writable storage", stderr, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(_dbDir, stderr, StringComparison.Ordinal); } [Fact] @@ -465,6 +469,11 @@ public void TryMigrateForRead_PartialDdlFailure_FailureSurvivesForLaterInspectio Assert.Contains("no such column", ex.Message, StringComparison.OrdinalIgnoreCase); } + private static object? GetActiveMigrationTransaction(DbContext db) + => typeof(DbContext) + .GetField("_activeMigrationTransaction", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(db); + [Fact] public void DbContext_ReadOnlyFilesystem_FallsBackToReadOnlyOpen() { From 9ea65ec9d786905213ed81833d292c8e0190487f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 01:16:27 +0900 Subject: [PATCH 02/10] Fix #3717 validate migration foreign keys --- changelog.d/unreleased/3717.fixed.md | 16 ++ src/CodeIndex/Database/DbContext.cs | 82 ++++++++++ tests/CodeIndex.Tests/DatabaseTests.cs | 198 +++++++++++++++---------- 3 files changed, 216 insertions(+), 80 deletions(-) create mode 100644 changelog.d/unreleased/3717.fixed.md diff --git a/changelog.d/unreleased/3717.fixed.md b/changelog.d/unreleased/3717.fixed.md new file mode 100644 index 0000000000..e4214c5b2e --- /dev/null +++ b/changelog.d/unreleased/3717.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 3717 +affected: + - src/CodeIndex/Database/DbContext.cs + - tests/CodeIndex.Tests/DatabaseTests.cs +--- + +## English + +- **Schema rebuild migrations now validate foreign keys before returning (#3717)** — migrations that temporarily disable SQLite FK enforcement now run bounded `foreign_key_check` diagnostics and fail with an actionable integrity-check hint if a violation is detected. + +## 日本語 + +- **schema rebuild migration が復帰前に外部キーを検証するようになりました (#3717)** — SQLite の FK enforcement を一時的に無効化する migration は、上限付きの `foreign_key_check` 診断を実行し、違反検出時は integrity-check の復旧ヒント付きで失敗します。 diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index d64440979e..5ae849366e 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -25,6 +25,7 @@ public class DbContext : IDisposable public const string DefaultSynchronousMode = "NORMAL"; public const string SymbolExtractorVersionMetaPrefix = "symbol_extractor_version_"; private const int MigrationDiagnosticTextLimit = 240; + private const int MigrationForeignKeyViolationSampleLimit = 5; private static readonly string[] RequiredCodeIndexTables = [ @@ -107,6 +108,7 @@ private static readonly (string Table, string Column)[] ReadMigrationRequiredCol private static readonly AsyncLocal?> ScopedPlannerStatisticsCommandCreatedForTesting = new(); private static readonly AsyncLocal?> ScopedPlannerStatisticsCommandExecutedForTesting = new(); private static readonly AsyncLocal?> ScopedWalCheckpointTruncateExecutedForTesting = new(); + private static readonly AsyncLocal?> ScopedForeignKeyValidationBeforeCheckForTesting = new(); internal static Action? OptimizePragmaExecutedForTesting { @@ -132,6 +134,12 @@ internal static Action? WalCheckpointTruncateExecutedForTesting set => ScopedWalCheckpointTruncateExecutedForTesting.Value = value; } + internal static Action? ForeignKeyValidationBeforeCheckForTesting + { + get => ScopedForeignKeyValidationBeforeCheckForTesting.Value; + set => ScopedForeignKeyValidationBeforeCheckForTesting.Value = value; + } + public SqliteConnection Connection => _connection; public bool IsReadOnly => _isReadOnly; public bool ReadOnlyFallback => _readOnlyFallback; @@ -1567,6 +1575,8 @@ private bool TableExists(string name) public void InitializeSchema() { var legacyAlterTable = ExecuteScalar("PRAGMA legacy_alter_table"); + var foreignKeys = ReadPragmaLong("foreign_keys"); + Execute("PRAGMA foreign_keys=OFF"); Execute("PRAGMA legacy_alter_table=ON"); try { @@ -1807,6 +1817,7 @@ CREATE TRIGGER IF NOT EXISTS fts_chunks_au AFTER UPDATE ON chunks BEGIN } finally { + Execute($"PRAGMA foreign_keys={foreignKeys}"); Execute($"PRAGMA legacy_alter_table={legacyAlterTable}"); _schemaCache?.Refresh(); } @@ -2071,12 +2082,14 @@ is_mutual_recursion INTEGER NOT NULL DEFAULT 0 Execute($"INSERT INTO symbol_references ({symbolReferencesColumns}) SELECT {symbolReferencesColumns} FROM {quotedOldSymbolReferences}"); Execute($"DROP TABLE {quotedOldSymbolReferences}"); Execute($"DROP TABLE {quotedOldReferenceLines}"); + InvokeForeignKeyValidationBeforeCheckForTesting("reference_lines_context_key"); } finally { Execute($"PRAGMA foreign_keys={foreignKeys}"); } + ValidateForeignKeysAfterMigration("reference_lines_context_key"); _schemaCache?.Refresh(); } @@ -2162,21 +2175,90 @@ is_mutual_recursion INTEGER NOT NULL DEFAULT 0 const string symbolReferencesColumns = "id, file_id, symbol_name, reference_kind, line, column_number, context, reference_line_id, container_kind, container_name, symbol_name_folded, container_name_folded, is_self_reference, is_mutual_recursion"; var foreignKeys = ReadPragmaLong("foreign_keys"); + var rebuilt = false; Execute("PRAGMA foreign_keys=OFF"); try { if (!TableCheckContainsAll("symbols", SymbolKindCatalog.SymbolKinds)) + { RebuildTableWithCurrentKindChecks("symbols", "_symbols_kind_check", symbolsCreateSql, symbolsColumns); + rebuilt = true; + } if (!TableCheckContainsAll("symbol_references", SymbolKindCatalog.SymbolKinds.Concat(SymbolKindCatalog.ReferenceKinds))) + { RebuildTableWithCurrentKindChecks("symbol_references", "_symbol_references_kind_check", symbolReferencesCreateSql, symbolReferencesColumns); + rebuilt = true; + } + + if (rebuilt) + InvokeForeignKeyValidationBeforeCheckForTesting("kind_check_constraints"); } finally { Execute($"PRAGMA foreign_keys={foreignKeys}"); } + + if (rebuilt) + ValidateForeignKeysAfterMigration("kind_check_constraints"); + } + + private void InvokeForeignKeyValidationBeforeCheckForTesting(string phase) + { + var boundedPhase = DiagnosticRedactor.BoundDiagnosticText(phase, MigrationDiagnosticTextLimit); + ForeignKeyValidationBeforeCheckForTesting?.Invoke(_connection, boundedPhase); } + private void ValidateForeignKeysAfterMigration(string phase) + { + var boundedPhase = DiagnosticRedactor.BoundDiagnosticText(phase, MigrationDiagnosticTextLimit); + using var cmd = _connection.CreateCommand(); + if (_activeMigrationTransaction != null) + cmd.Transaction = _activeMigrationTransaction; + cmd.CommandText = "PRAGMA foreign_key_check"; + + var violations = new List(); + var violationCount = 0; + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + violationCount++; + if (violations.Count < MigrationForeignKeyViolationSampleLimit) + violations.Add(FormatForeignKeyViolation(reader)); + } + + if (violationCount == 0) + return; + + var sample = string.Join("; ", violations); + var truncated = violationCount > violations.Count + ? $" (showing {violations.Count.ToString(CultureInfo.InvariantCulture)})" + : string.Empty; + throw new CodeIndexException( + code: CommandErrorCodes.DbIntegrityFailed, + category: CodeIndexExceptionCategory.Database, + message: $"Foreign key validation failed after schema migration phase '{boundedPhase}' with {violationCount.ToString(CultureInfo.InvariantCulture)} violation(s){truncated}: {sample}.", + hint: "Run `cdidx db --integrity-check --db ` and rebuild the index on writable storage if violations persist."); + } + + private static string FormatForeignKeyViolation(SqliteDataReader reader) + { + var table = FormatForeignKeyCheckValue(reader.IsDBNull(0) ? "" : reader.GetString(0)); + var rowId = reader.IsDBNull(1) + ? "" + : Convert.ToInt64(reader.GetValue(1), CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); + var parent = FormatForeignKeyCheckValue(reader.IsDBNull(2) ? "" : reader.GetString(2)); + var fkId = reader.IsDBNull(3) + ? "" + : Convert.ToInt64(reader.GetValue(3), CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture); + return $"table={table}, rowid={rowId}, parent={parent}, fkid={fkId}"; + } + + private static string FormatForeignKeyCheckValue(string value) + => DiagnosticRedactor.BoundDiagnosticText( + DiagnosticRedactor.RedactSensitiveText(value, redactPaths: true), + MigrationDiagnosticTextLimit); + private bool TableCheckContainsAll(string tableName, IEnumerable allowedValues) { var createSql = GetTableCreateSql(tableName); diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index 073567bb1d..d72fd32250 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -452,86 +452,7 @@ public void InitializeSchema_RefreshesLegacyKindCheckConstraints() var dbPath = Path.Combine(Path.GetTempPath(), $"codeindex_kind_check_{Guid.NewGuid():N}.db"); try { - var builder = new SqliteConnectionStringBuilder { DataSource = dbPath }; - using (var conn = new SqliteConnection(builder.ConnectionString)) - { - conn.Open(); - ExecuteNonQuery(conn, """ - CREATE TABLE files ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - path TEXT NOT NULL UNIQUE, - lang TEXT, - size INTEGER, - lines INTEGER, - checksum TEXT, - modified DATETIME, - generated INTEGER NOT NULL DEFAULT 0, - indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP - ) - """); - ExecuteNonQuery(conn, """ - CREATE TABLE chunks ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - chunk_index INTEGER NOT NULL, - start_line INTEGER, - end_line INTEGER, - content TEXT, - UNIQUE(file_id, chunk_index) - ) - """); - ExecuteNonQuery(conn, """ - CREATE TABLE reference_lines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - line INTEGER NOT NULL, - context TEXT NOT NULL, - UNIQUE(file_id, line, context) - ) - """); - ExecuteNonQuery(conn, """ - CREATE TABLE symbols ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - kind TEXT CHECK (kind IN ('class','function','module')), - sub_kind TEXT, - name TEXT, - line INTEGER, - start_line INTEGER, - start_column INTEGER, - end_line INTEGER, - body_start_line INTEGER, - body_end_line INTEGER, - signature TEXT, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ('class','function','module')), - container_name TEXT, - container_qualified_name TEXT, - family_key TEXT, - visibility TEXT, - return_type TEXT, - is_metadata_target INTEGER, - name_folded TEXT - ) - """); - ExecuteNonQuery(conn, """ - CREATE TABLE symbol_references ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, - symbol_name TEXT, - reference_kind TEXT CHECK (reference_kind IN ('call','type_reference')), - line INTEGER, - column_number INTEGER, - context TEXT, - reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, - container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ('class','function','module')), - container_name TEXT, - symbol_name_folded TEXT, - container_name_folded TEXT, - is_self_reference INTEGER NOT NULL DEFAULT 0, - is_mutual_recursion INTEGER NOT NULL DEFAULT 0 - ) - """); - } + SeedLegacyKindCheckSchema(dbPath); using var db = new DbContext(dbPath); db.InitializeSchema(); @@ -580,6 +501,41 @@ is_mutual_recursion INTEGER NOT NULL DEFAULT 0 } } + [Fact] + public void InitializeSchema_ForeignKeyCheckDetectsRebuildViolations_Issue3717() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"codeindex_fk_check_{Guid.NewGuid():N}.db"); + try + { + SeedLegacyKindCheckSchema(dbPath); + DbContext.ForeignKeyValidationBeforeCheckForTesting = (connection, phase) => + { + if (!string.Equals(phase, "kind_check_constraints", StringComparison.Ordinal)) + return; + + ExecuteNonQuery(connection, """ + INSERT INTO symbol_references (file_id, symbol_name, reference_kind, line, column_number, context) + VALUES (9999, 'Dangling', 'call', 1, 1, 'Dangling()') + """); + }; + + using var db = new DbContext(dbPath); + var ex = Assert.Throws(db.InitializeSchema); + + Assert.Equal(CommandErrorCodes.DbIntegrityFailed, ex.Code); + Assert.Contains("kind_check_constraints", ex.Message, StringComparison.Ordinal); + Assert.Contains("symbol_references", ex.Message, StringComparison.Ordinal); + Assert.Contains("files", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain(dbPath, ex.Message, StringComparison.Ordinal); + Assert.Contains("integrity-check", ex.Hint, StringComparison.Ordinal); + } + finally + { + DbContext.ForeignKeyValidationBeforeCheckForTesting = null; + DeleteDbFiles(dbPath); + } + } + [Fact] public void OptimizeFts_ResetsIncrementalWriteCounterAndStampsTime() { @@ -2955,4 +2911,86 @@ private static void ExecuteNonQuery(SqliteConnection connection, string sql) cmd.CommandText = sql; cmd.ExecuteNonQuery(); } + + private static void SeedLegacyKindCheckSchema(string dbPath) + { + var builder = new SqliteConnectionStringBuilder { DataSource = dbPath }; + using var conn = new SqliteConnection(builder.ConnectionString); + conn.Open(); + ExecuteNonQuery(conn, """ + CREATE TABLE files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL UNIQUE, + lang TEXT, + size INTEGER, + lines INTEGER, + checksum TEXT, + modified DATETIME, + generated INTEGER NOT NULL DEFAULT 0, + indexed_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """); + ExecuteNonQuery(conn, """ + CREATE TABLE chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + chunk_index INTEGER NOT NULL, + start_line INTEGER, + end_line INTEGER, + content TEXT, + UNIQUE(file_id, chunk_index) + ) + """); + ExecuteNonQuery(conn, """ + CREATE TABLE reference_lines ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + line INTEGER NOT NULL, + context TEXT NOT NULL, + UNIQUE(file_id, line, context) + ) + """); + ExecuteNonQuery(conn, """ + CREATE TABLE symbols ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + kind TEXT CHECK (kind IN ('class','function','module')), + sub_kind TEXT, + name TEXT, + line INTEGER, + start_line INTEGER, + start_column INTEGER, + end_line INTEGER, + body_start_line INTEGER, + body_end_line INTEGER, + signature TEXT, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ('class','function','module')), + container_name TEXT, + container_qualified_name TEXT, + family_key TEXT, + visibility TEXT, + return_type TEXT, + is_metadata_target INTEGER, + name_folded TEXT + ) + """); + ExecuteNonQuery(conn, """ + CREATE TABLE symbol_references ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE, + symbol_name TEXT, + reference_kind TEXT CHECK (reference_kind IN ('call','type_reference')), + line INTEGER, + column_number INTEGER, + context TEXT, + reference_line_id INTEGER REFERENCES reference_lines(id) ON DELETE SET NULL, + container_kind TEXT CHECK (container_kind IS NULL OR container_kind IN ('class','function','module')), + container_name TEXT, + symbol_name_folded TEXT, + container_name_folded TEXT, + is_self_reference INTEGER NOT NULL DEFAULT 0, + is_mutual_recursion INTEGER NOT NULL DEFAULT 0 + ) + """); + } } From a7fe472ec849a0e80fa93801a93a3ef8ffa88c37 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 01:29:51 +0900 Subject: [PATCH 03/10] Fix #3832 read-only fallback diagnostics --- changelog.d/unreleased/3832.fixed.md | 22 +++++++ src/CodeIndex/Cli/JsonOutputContracts.cs | 18 +++++- src/CodeIndex/Cli/QueryCommandRunner.cs | 57 +++++++++++++++-- src/CodeIndex/Database/DbConnectionFactory.cs | 37 +++++++++-- src/CodeIndex/Database/DbContext.cs | 39 +++++++++-- .../Database/DbReader.FilesStatus.cs | 5 ++ src/CodeIndex/Database/DbReader.cs | 32 +++++++++- src/CodeIndex/Models/QueryResults.cs | 13 ++++ .../LegacySchemaMigrationTests.cs | 64 ++++++++++++++++++- 9 files changed, 266 insertions(+), 21 deletions(-) create mode 100644 changelog.d/unreleased/3832.fixed.md diff --git a/changelog.d/unreleased/3832.fixed.md b/changelog.d/unreleased/3832.fixed.md new file mode 100644 index 0000000000..f2175d7db1 --- /dev/null +++ b/changelog.d/unreleased/3832.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 3832 +affected: + - src/CodeIndex/Database/DbConnectionFactory.cs + - src/CodeIndex/Database/DbContext.cs + - src/CodeIndex/Database/DbReader.cs + - src/CodeIndex/Database/DbReader.FilesStatus.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs +--- + +## English + +- **Read-only SQLite fallback now reports stale-WAL diagnostics (#3832)** — immutable fallback is limited to read-only/cantopen/io-style open failures, while status and query JSON expose checkpoint skip/failure reasons and stale snapshot risk when WAL freshness cannot be guaranteed. + +## 日本語 + +- **read-only SQLite fallback が stale WAL 診断を返すようになりました (#3832)** — immutable fallback は read-only/cantopen/io 系の open 失敗に限定し、status と query JSON には WAL freshness を保証できない場合の checkpoint skip/failure reason と stale snapshot risk を出力します。 diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 261f01c0e6..fca00e98f3 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -284,7 +284,23 @@ internal sealed record QueryPathErrorJsonResult( internal sealed record JsonStreamDoneResult( [property: JsonPropertyName("done")] bool Done, [property: JsonPropertyName("count")] int Count, - [property: JsonPropertyName("interrupted")] bool Interrupted); + [property: JsonPropertyName("interrupted")] bool Interrupted, + [property: JsonPropertyName("read_only_fallback")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? ReadOnlyFallback = null, + [property: JsonPropertyName("wal_checkpoint_attempted")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? WalCheckpointAttempted = null, + [property: JsonPropertyName("wal_checkpoint_succeeded")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? WalCheckpointSucceeded = null, + [property: JsonPropertyName("read_only_immutable_fallback")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? ReadOnlyImmutableFallback = null, + [property: JsonPropertyName("wal_checkpoint_skipped_reason")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? WalCheckpointSkippedReason = null, + [property: JsonPropertyName("wal_checkpoint_failure_reason")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? WalCheckpointFailureReason = null, + [property: JsonPropertyName("wal_stale_snapshot_risk")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? WalStaleSnapshotRisk = null, + [property: JsonPropertyName("wal_stale_snapshot_reason")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? WalStaleSnapshotReason = null); internal sealed record LanguageEntryJsonResult( [property: JsonPropertyName("lang")] string Lang, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index e0f1eba734..383a827c4d 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -781,8 +781,10 @@ public static int RunSearch( var ndjsonOptions = options.JsonOutputFormat == JsonOutputFormatNdjson ? GetCompactJsonOptions(jsonOptions) : jsonOptions; int? jsonDoneCount = null; var jsonDoneInterrupted = false; + DbReader? jsonDoneReader = null; return WithDb(options, jsonOptions, reader => { + jsonDoneReader = reader; if (options.GroupBy != null) { return RunGroupedSearchCount(reader, options, jsonOptions, exact, exactSubstringHint); @@ -969,7 +971,7 @@ public static int RunSearch( }, exitCode => { if (options.Json && options.JsonOutputFormat == JsonOutputFormatNdjson && jsonDoneCount.HasValue && !options.ResultsOnly) - WriteJsonStreamDone(jsonDoneCount.Value, ndjsonOptions, jsonDoneInterrupted); + WriteJsonStreamDone(jsonDoneCount.Value, ndjsonOptions, jsonDoneInterrupted, jsonDoneReader); }); } @@ -2896,10 +2898,24 @@ private static void AttachExactSubstringHint(IEnumerable re result.ExactSubstringHint = hint; } - private static void WriteJsonStreamDone(int count, JsonSerializerOptions jsonOptions, bool interrupted = false) - => Console.WriteLine(JsonSerializer.Serialize( - new JsonStreamDoneResult(Done: true, Count: count, Interrupted: interrupted), + private static void WriteJsonStreamDone(int count, JsonSerializerOptions jsonOptions, bool interrupted = false, DbReader? reader = null) + { + var includeDiagnostics = HasReadOnlyFallbackDiagnostics(reader); + Console.WriteLine(JsonSerializer.Serialize( + new JsonStreamDoneResult( + Done: true, + Count: count, + Interrupted: interrupted, + ReadOnlyFallback: includeDiagnostics ? reader!.ReadOnlyFallback : null, + WalCheckpointAttempted: includeDiagnostics ? reader!.WalCheckpointAttempted : null, + WalCheckpointSucceeded: includeDiagnostics ? reader!.WalCheckpointSucceeded : null, + ReadOnlyImmutableFallback: includeDiagnostics ? reader!.ReadOnlyImmutableFallback : null, + WalCheckpointSkippedReason: includeDiagnostics ? reader!.WalCheckpointSkippedReason : null, + WalCheckpointFailureReason: includeDiagnostics ? reader!.WalCheckpointFailureReason : null, + WalStaleSnapshotRisk: includeDiagnostics ? reader!.WalStaleSnapshotRisk : null, + WalStaleSnapshotReason: includeDiagnostics ? reader!.WalStaleSnapshotReason : null), CliJsonSerializerContextFactory.Create(jsonOptions).JsonStreamDoneResult)); + } private static JsonSerializerOptions GetCompactJsonOptions(JsonSerializerOptions jsonOptions) => jsonOptions.WriteIndented ? new JsonSerializerOptions(jsonOptions) { WriteIndented = false } : jsonOptions; @@ -11976,8 +11992,38 @@ private static void AddFreshnessHint(JsonObject payload, DbReader reader) payload["freshness_available"] = freshness.FreshnessAvailable; if (!freshness.FreshnessAvailable && freshness.FreshnessDegradedReason != null) payload["freshness_degraded_reason"] = freshness.FreshnessDegradedReason; + AddReadOnlyFallbackDiagnostics(payload, reader); } + internal static void AddReadOnlyFallbackDiagnostics(JsonObject payload, DbReader reader) + { + if (!HasReadOnlyFallbackDiagnostics(reader)) + { + return; + } + + payload["read_only_fallback"] = reader.ReadOnlyFallback; + payload["wal_checkpoint_attempted"] = reader.WalCheckpointAttempted; + payload["wal_checkpoint_succeeded"] = reader.WalCheckpointSucceeded; + payload["read_only_immutable_fallback"] = reader.ReadOnlyImmutableFallback; + if (reader.WalCheckpointSkippedReason != null) + payload["wal_checkpoint_skipped_reason"] = reader.WalCheckpointSkippedReason; + if (reader.WalCheckpointFailureReason != null) + payload["wal_checkpoint_failure_reason"] = reader.WalCheckpointFailureReason; + payload["wal_stale_snapshot_risk"] = reader.WalStaleSnapshotRisk; + if (reader.WalStaleSnapshotReason != null) + payload["wal_stale_snapshot_reason"] = reader.WalStaleSnapshotReason; + } + + private static bool HasReadOnlyFallbackDiagnostics(DbReader? reader) + => reader != null + && (reader.ReadOnlyFallback + || reader.WalCheckpointAttempted + || reader.ReadOnlyImmutableFallback + || reader.WalCheckpointSkippedReason != null + || reader.WalCheckpointFailureReason != null + || reader.WalStaleSnapshotRisk); + private static JsonObject BuildCountJsonPayload( DbReader reader, JsonSerializerOptions jsonOptions, @@ -12045,7 +12091,8 @@ private static void AddCountAuthorityJsonFields(JsonObject payload) || JsonBool(payload, "scan_truncated") == true || JsonBool(payload, "scan_cap_reached") == true || JsonBool(payload, "scan_timed_out") == true - || JsonBool(payload, "truncated") == true; + || JsonBool(payload, "truncated") == true + || JsonBool(payload, "wal_stale_snapshot_risk") == true; payload["degraded"] = degraded; payload["authoritative_count"] = !degraded; } diff --git a/src/CodeIndex/Database/DbConnectionFactory.cs b/src/CodeIndex/Database/DbConnectionFactory.cs index 1fc081655b..b67ffa9e1f 100644 --- a/src/CodeIndex/Database/DbConnectionFactory.cs +++ b/src/CodeIndex/Database/DbConnectionFactory.cs @@ -91,28 +91,52 @@ internal static string ToReadOnlyUri(string dbPath) return $"{fileUri}?immutable=1&mode=ro"; } + internal const string FileUriPathParseFailedReason = "file_uri_path_parse_failed"; + internal const string FileUriParseFailedReason = "file_uri_parse_failed"; + internal const string FileUriNotLocalFileReason = "file_uri_not_local_file"; + // Best-effort: extract the filesystem path from a SQLite URI so -wal checks can run. - // Returns null if parsing fails; the caller simply skips the gate in that case. - // URI から filesystem path を取り出すベストエフォート。失敗したらゲートをスキップ。 + // Returns null if parsing fails; the caller records why the freshness gate was skipped. + // URI から filesystem path を取り出すベストエフォート。失敗理由は freshness 診断に載せる。 internal static string? TryGetLocalPath(string uriText) + => TryGetLocalPath(uriText, out var localPath, out _) ? localPath : null; + + internal static bool TryGetLocalPath(string uriText, out string? localPath, out string? failureReason) { + localPath = null; + failureReason = null; try { // Trim the query string (?immutable=1 etc.) before parsing so LocalPath is clean. if (!SqliteFileUri.TryGetPathBeforeQuery(uriText, out var trimmed, out _)) - return null; + { + failureReason = FileUriPathParseFailedReason; + return false; + } var uri = new Uri(trimmed); - return uri.IsFile ? uri.LocalPath : null; + if (!uri.IsFile) + { + failureReason = FileUriNotLocalFileReason; + return false; + } + + localPath = uri.LocalPath; + return true; } catch (UriFormatException) { - return null; + failureReason = FileUriParseFailedReason; + return false; } } internal static SqliteConnection OpenReadOnly(string dbPath) + => OpenReadOnly(dbPath, out _); + + internal static SqliteConnection OpenReadOnly(string dbPath, out bool usedImmutableFallback) { + usedImmutableFallback = false; // Attempt 1: Mode=ReadOnly. Works for most read-only FS scenarios and, crucially, // still reads hot -wal state so nothing committed but not yet checkpointed is lost. // 第一段: Mode=ReadOnly。多くの read-only 環境で動作し、hot -wal の未チェックポイント @@ -128,7 +152,7 @@ internal static SqliteConnection OpenReadOnly(string dbPath) conn.Open(); return conn; } - catch (SqliteException) + catch (SqliteException ex) when (IsReadOnlyOpenError(ex)) { // Attempt 2: immutable=1 URI. This bypasses -shm/-wal entirely, which is the only // way to survive a sandbox that cannot touch side files. Trade-off documented: @@ -160,6 +184,7 @@ internal static SqliteConnection OpenReadOnly(string dbPath) var rawConnStr = $"Data Source={fileUri}?immutable=1;Mode=ReadOnly"; var conn = new SqliteConnection(rawConnStr); conn.Open(); + usedImmutableFallback = true; return conn; } } diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 5ae849366e..c1dd7f2fda 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -94,6 +94,9 @@ private static readonly (string Table, string Column)[] ReadMigrationRequiredCol private bool _readOnlyFallback; private bool _walCheckpointAttempted; private bool _walCheckpointSucceeded; + private bool _readOnlyImmutableFallback; + private string? _walCheckpointSkippedReason; + private string? _walCheckpointFailureReason; private readonly string? _schemaCacheKey; private SqliteTransaction? _activeMigrationTransaction; private bool _readMigrationInsideExternalTransaction; @@ -145,6 +148,9 @@ internal static Action? ForeignKeyValidationBeforeChec public bool ReadOnlyFallback => _readOnlyFallback; public bool WalCheckpointAttempted => _walCheckpointAttempted; public bool WalCheckpointSucceeded => _walCheckpointSucceeded; + public bool ReadOnlyImmutableFallback => _readOnlyImmutableFallback; + public string? WalCheckpointSkippedReason => _walCheckpointSkippedReason; + public string? WalCheckpointFailureReason => _walCheckpointFailureReason; public static string GetSymbolExtractorVersionMetaKey(string lang) => SymbolExtractorVersionMetaPrefix + lang; @@ -327,12 +333,16 @@ public DbContext(string dbPath, CancellationToken cancellationToken = default) // Bare file: URI — normalize to a filesystem path and fall through. // immutable/mode=ro 指定のない file: URI はローカルパスに戻して通常経路で開く。 - var normalized = TryGetLocalPath(dbPath); - if (normalized != null) + if (TryGetLocalPath(dbPath, out var normalized, out var pathFailureReason) + && normalized != null) { dbPath = normalized; _schemaCacheKey = TryCreateSchemaCacheKey(dbPath); } + else + { + _walCheckpointSkippedReason = pathFailureReason; + } } // Use SqliteConnectionStringBuilder to prevent connection string injection @@ -377,7 +387,8 @@ public DbContext(string dbPath, CancellationToken cancellationToken = default) // immutable=1 を付けないと SQLite は -shm/-wal を触ろうとして CANTOPEN で落ちることがある。 _connection?.Dispose(); _walCheckpointAttempted = true; - _walCheckpointSucceeded = TryCheckpointWalBeforeReadOnlyFallback(dbPath, cancellationToken); + _walCheckpointSucceeded = _walCheckpointSkippedReason == null + && TryCheckpointWalBeforeReadOnlyFallback(dbPath, cancellationToken, out _walCheckpointFailureReason); if (_walCheckpointSucceeded) { try @@ -444,7 +455,7 @@ public DbContext(string dbPath, CancellationToken cancellationToken = default) private void OpenReadOnlyFallback(string dbPath, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - _connection = OpenReadOnly(dbPath); + _connection = OpenReadOnly(dbPath, out _readOnlyImmutableFallback); ApplyBusyTimeoutPragma(); ApplyConnectionPerformancePragmas(); RegisterConnectionFunctionsWithRetry(_connection, cancellationToken: cancellationToken); @@ -452,8 +463,12 @@ private void OpenReadOnlyFallback(string dbPath, CancellationToken cancellationT WarnIfBatchInProgress(); } - private static bool TryCheckpointWalBeforeReadOnlyFallback(string dbPath, CancellationToken cancellationToken) + private static bool TryCheckpointWalBeforeReadOnlyFallback( + string dbPath, + CancellationToken cancellationToken, + out string? failureReason) { + failureReason = null; try { var builder = new SqliteConnectionStringBuilder @@ -474,10 +489,18 @@ private static bool TryCheckpointWalBeforeReadOnlyFallback(string dbPath, Cancel } catch (Exception ex) when (ex is SqliteException or CodeIndexException) { + failureReason = FormatWalCheckpointFailureReason(ex); return false; } } + private static string FormatWalCheckpointFailureReason(Exception ex) => ex switch + { + SqliteException sqlite => $"sqlite_error_{sqlite.SqliteErrorCode.ToString(CultureInfo.InvariantCulture)}", + CodeIndexException codeIndexException => codeIndexException.Code, + _ => "wal_checkpoint_failed", + }; + public bool TryCheckpointWalTruncate() { if (_isReadOnly) @@ -763,9 +786,15 @@ internal static SqliteConnection OpenSqliteConnectionWithRetry( private static string? TryGetLocalPath(string uriText) => DbConnectionFactory.TryGetLocalPath(uriText); + private static bool TryGetLocalPath(string uriText, out string? localPath, out string? failureReason) + => DbConnectionFactory.TryGetLocalPath(uriText, out localPath, out failureReason); + private static SqliteConnection OpenReadOnly(string dbPath) => DbConnectionFactory.OpenReadOnly(dbPath); + private static SqliteConnection OpenReadOnly(string dbPath, out bool usedImmutableFallback) + => DbConnectionFactory.OpenReadOnly(dbPath, out usedImmutableFallback); + internal static void RegisterConnectionFunctions(SqliteConnection connection) { static int? ToNullableInt(long? value) diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index f0bfd54161..d77df0bcd8 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -885,6 +885,11 @@ GROUP BY COALESCE(f.lang, 'unknown'), s.kind ReadOnlyFallback = _readOnlyFallback, WalCheckpointAttempted = _walCheckpointAttempted, WalCheckpointSucceeded = _walCheckpointSucceeded, + ReadOnlyImmutableFallback = _readOnlyImmutableFallback, + WalCheckpointSkippedReason = _walCheckpointSkippedReason, + WalCheckpointFailureReason = _walCheckpointFailureReason, + WalStaleSnapshotRisk = WalStaleSnapshotRisk, + WalStaleSnapshotReason = WalStaleSnapshotReason, }; // Commit the read-only snapshot explicitly so the SHARED lock is released promptly. // read-only なので rollback でも同じだが、明示 commit して SHARED lock を早期解放する。 diff --git a/src/CodeIndex/Database/DbReader.cs b/src/CodeIndex/Database/DbReader.cs index b661465172..2daab4089c 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -51,6 +51,9 @@ public partial class DbReader : IDisposable private readonly bool _readOnlyFallback; private readonly bool _walCheckpointAttempted; private readonly bool _walCheckpointSucceeded; + private readonly bool _readOnlyImmutableFallback; + private readonly string? _walCheckpointSkippedReason; + private readonly string? _walCheckpointFailureReason; private readonly DbSchemaCache? _schemaCache; private readonly CancellationToken _cancellation; private readonly HashSet _fileColumns; @@ -433,7 +436,10 @@ public DbReader(DbContext context) context.PreparedCommands, context.ReadOnlyFallback, context.WalCheckpointAttempted, - context.WalCheckpointSucceeded) + context.WalCheckpointSucceeded, + context.ReadOnlyImmutableFallback, + context.WalCheckpointSkippedReason, + context.WalCheckpointFailureReason) { } @@ -451,7 +457,10 @@ public DbReader(DbContext context, CancellationToken cancellation) context.PreparedCommands, context.ReadOnlyFallback, context.WalCheckpointAttempted, - context.WalCheckpointSucceeded) + context.WalCheckpointSucceeded, + context.ReadOnlyImmutableFallback, + context.WalCheckpointSkippedReason, + context.WalCheckpointFailureReason) { } @@ -485,7 +494,10 @@ private DbReader( PreparedCommandCache? commandCache, bool readOnlyFallback = false, bool walCheckpointAttempted = false, - bool walCheckpointSucceeded = false) + bool walCheckpointSucceeded = false, + bool readOnlyImmutableFallback = false, + string? walCheckpointSkippedReason = null, + string? walCheckpointFailureReason = null) { _conn = connection; _commandCache = commandCache; @@ -498,6 +510,9 @@ private DbReader( _readOnlyFallback = readOnlyFallback; _walCheckpointAttempted = walCheckpointAttempted; _walCheckpointSucceeded = walCheckpointSucceeded; + _readOnlyImmutableFallback = readOnlyImmutableFallback; + _walCheckpointSkippedReason = walCheckpointSkippedReason; + _walCheckpointFailureReason = walCheckpointFailureReason; _schemaCache = schemaCache; _cancellation = cancellation; _fileColumns = LoadColumns("files"); @@ -1012,6 +1027,17 @@ private bool HasTable(string tableName) return DbSchemaCache.QueryHasTable(_conn, tableName); } + public bool ReadOnlyFallback => _readOnlyFallback; + public bool WalCheckpointAttempted => _walCheckpointAttempted; + public bool WalCheckpointSucceeded => _walCheckpointSucceeded; + public bool ReadOnlyImmutableFallback => _readOnlyImmutableFallback; + public bool WalStaleSnapshotRisk => _readOnlyImmutableFallback && !_walCheckpointSucceeded; + public string? WalCheckpointSkippedReason => _walCheckpointSkippedReason; + public string? WalCheckpointFailureReason => _walCheckpointFailureReason; + public string? WalStaleSnapshotReason => WalStaleSnapshotRisk + ? _walCheckpointSkippedReason ?? _walCheckpointFailureReason ?? "immutable_read_only_fallback" + : null; + private bool HasSymbolIndex(string indexName) => _symbolIndexes.Contains(indexName); private bool HasReferenceIndex(string indexName) => _referenceIndexes.Contains(indexName); diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index cc8298c080..b64799f92b 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -825,6 +825,19 @@ public class StatusResult public bool WalCheckpointAttempted { get; set; } [JsonPropertyName("wal_checkpoint_succeeded")] public bool WalCheckpointSucceeded { get; set; } + [JsonPropertyName("read_only_immutable_fallback")] + public bool ReadOnlyImmutableFallback { get; set; } + [JsonPropertyName("wal_checkpoint_skipped_reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WalCheckpointSkippedReason { get; set; } + [JsonPropertyName("wal_checkpoint_failure_reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WalCheckpointFailureReason { get; set; } + [JsonPropertyName("wal_stale_snapshot_risk")] + public bool WalStaleSnapshotRisk { get; set; } + [JsonPropertyName("wal_stale_snapshot_reason")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? WalStaleSnapshotReason { get; set; } public string? GitHead { get; set; } public bool? GitIsDirty { get; set; } /// diff --git a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs index e260b2c3f8..76d0664189 100644 --- a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs +++ b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Reflection; +using System.Text.Json.Nodes; using CodeIndex.Cli; using CodeIndex.Database; using CodeIndex.Models; @@ -474,6 +475,11 @@ public void TryMigrateForRead_PartialDdlFailure_FailureSurvivesForLaterInspectio .GetField("_activeMigrationTransaction", BindingFlags.Instance | BindingFlags.NonPublic)! .GetValue(db); + private static void SetDbContextField(DbContext db, string fieldName, T value) + => typeof(DbContext) + .GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(db, value); + [Fact] public void DbContext_ReadOnlyFilesystem_FallsBackToReadOnlyOpen() { @@ -500,12 +506,28 @@ public void DbContext_ReadOnlyFilesystem_FallsBackToReadOnlyOpen() using var db = new DbContext(_dbPath); Assert.True(db.IsReadOnly, "DbContext should have fallen back to read-only open."); + Assert.True(db.ReadOnlyFallback); + Assert.True(db.WalCheckpointAttempted); // Migration is skipped (catches SQLITE_READONLY). Reader still builds and query // paths — including the previously unguarded deps / issues — degrade to empty. // マイグレーションはスキップされ、deps / issues も空で縮退する。 db.TryMigrateForRead(); - var reader = new DbReader(db.Connection); + var reader = new DbReader(db); + Assert.True(reader.ReadOnlyFallback); + Assert.Equal(db.WalCheckpointAttempted, reader.WalCheckpointAttempted); + Assert.Equal(db.WalCheckpointSucceeded, reader.WalCheckpointSucceeded); + Assert.Equal(db.ReadOnlyImmutableFallback, reader.ReadOnlyImmutableFallback); + Assert.Equal(reader.ReadOnlyImmutableFallback && !reader.WalCheckpointSucceeded, reader.WalStaleSnapshotRisk); + + var status = reader.GetStatus(); + Assert.True(status.ReadOnlyFallback); + Assert.Equal(reader.WalCheckpointAttempted, status.WalCheckpointAttempted); + Assert.Equal(reader.WalCheckpointSucceeded, status.WalCheckpointSucceeded); + Assert.Equal(reader.ReadOnlyImmutableFallback, status.ReadOnlyImmutableFallback); + Assert.Equal(reader.WalStaleSnapshotRisk, status.WalStaleSnapshotRisk); + Assert.Equal(reader.WalStaleSnapshotReason, status.WalStaleSnapshotReason); + Assert.Empty(reader.GetFileDependencies()); Assert.Empty(reader.GetIssues()); @@ -520,6 +542,46 @@ public void DbContext_ReadOnlyFilesystem_FallsBackToReadOnlyOpen() } } + [Fact] + public void ReadOnlyFallbackDiagnostics_AddsStaleWalRiskToQueryPayload() + { + using var db = new DbContext(_dbPath); + SetDbContextField(db, "_readOnlyFallback", true); + SetDbContextField(db, "_walCheckpointAttempted", true); + SetDbContextField(db, "_walCheckpointSucceeded", false); + SetDbContextField(db, "_readOnlyImmutableFallback", true); + SetDbContextField(db, "_walCheckpointSkippedReason", DbConnectionFactory.FileUriPathParseFailedReason); + + var reader = new DbReader(db); + var payload = new JsonObject(); + QueryCommandRunner.AddReadOnlyFallbackDiagnostics(payload, reader); + + Assert.True(payload["read_only_fallback"]!.GetValue()); + Assert.True(payload["wal_checkpoint_attempted"]!.GetValue()); + Assert.False(payload["wal_checkpoint_succeeded"]!.GetValue()); + Assert.True(payload["read_only_immutable_fallback"]!.GetValue()); + Assert.Equal(DbConnectionFactory.FileUriPathParseFailedReason, payload["wal_checkpoint_skipped_reason"]!.GetValue()); + Assert.True(payload["wal_stale_snapshot_risk"]!.GetValue()); + Assert.Equal(DbConnectionFactory.FileUriPathParseFailedReason, payload["wal_stale_snapshot_reason"]!.GetValue()); + } + + [Fact] + public void DbConnectionFactory_ReadOnlyFallbackClassification_RejectsOrdinarySqliteErrors() + { + Assert.True(DbConnectionFactory.IsReadOnlyOpenError(CreateSqliteException("readonly", 8))); + Assert.True(DbConnectionFactory.IsReadOnlyOpenError(CreateSqliteException("io error", 10))); + Assert.True(DbConnectionFactory.IsReadOnlyOpenError(CreateSqliteException("cantopen", 14))); + Assert.False(DbConnectionFactory.IsReadOnlyOpenError(CreateSqliteException("not a database", 26))); + } + + [Fact] + public void DbConnectionFactory_TryGetLocalPath_ReportsMachineReadableFailureReason() + { + Assert.False(DbConnectionFactory.TryGetLocalPath("https://example.invalid/codeindex.db", out var localPath, out var reason)); + Assert.Null(localPath); + Assert.Equal(DbConnectionFactory.FileUriNotLocalFileReason, reason); + } + [Fact] public void DbContext_OpenSqliteConnectionWithRetry_RetriesTransientBusy() { From 5703f0c93721400599f56a043887ee86d3c5b1a4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 01:35:47 +0900 Subject: [PATCH 04/10] Fix #3812 harden checkpoint restore --- changelog.d/unreleased/3812.fixed.md | 17 ++++ src/CodeIndex/Cli/DbCommandRunner.cs | 66 +++++++++------ tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 82 ++++++++++++++++++- 3 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 changelog.d/unreleased/3812.fixed.md diff --git a/changelog.d/unreleased/3812.fixed.md b/changelog.d/unreleased/3812.fixed.md new file mode 100644 index 0000000000..30b90b000d --- /dev/null +++ b/changelog.d/unreleased/3812.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3812 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs +--- + +## English + +- **DB checkpoint listing and restore rollback are more fault-tolerant (#3812)** — checkpoint file enumeration now reports bounded diagnostics instead of failing after a successful snapshot, and restore rollback moves backed-up files back with private permissions without deleting current targets first. + +## 日本語 + +- **DB checkpoint listing と restore rollback の耐障害性を高めました (#3812)** — checkpoint file enumeration は snapshot 成功後に失敗させず上限付き診断として返し、restore rollback は現在の target を先に削除せず private permission を保ったまま backup を戻します。 diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 31e8b4ae32..d277335bf2 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -29,6 +29,7 @@ public static class DbCommandRunner private static readonly char[] InvalidCheckpointNameChars = Path.GetInvalidFileNameChars(); internal static Action? RestoreFailureAfterBackupForTesting { get; set; } internal static Action? DeleteTemporaryDirectoryForTesting { get; set; } + internal static Func>? EnumerateCheckpointFilesForTesting { get; set; } internal static Func>? IntegrityCheckRowsForTesting { get; set; } internal static Func>? EnumerateCheckpointFileNamesForTesting { get; set; } @@ -341,7 +342,7 @@ private static int RunCheckpoint(DbCommandOptions options, JsonSerializerOptions result.Files, result.FilesTruncated, CheckpointFileInspectLimit, - result.Diagnostics), + result.Diagnostics.Count > 0 ? result.Diagnostics : null), CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointJsonResult)); } else @@ -351,10 +352,11 @@ private static int RunCheckpoint(DbCommandOptions options, JsonSerializerOptions Console.WriteLine($" name : {result.Name}"); Console.WriteLine($" checkpoint: {result.CheckpointPath}"); Console.WriteLine($" files : {ConsoleUi.Counted(result.Files.Count, "file")}{(result.FilesTruncated ? " (truncated)" : string.Empty)}"); - foreach (var diagnostic in result.Diagnostics) - Console.Error.WriteLine($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); } + foreach (var diagnostic in result.Diagnostics) + CommandErrorWriter.WriteStderr($"Warning [{diagnostic.Code}]: {diagnostic.Message}"); + return CommandExitCodes.Success; } catch (Exception ex) @@ -1164,23 +1166,42 @@ private static (List Items, bool Truncated) EnumerateCheckpointFileNames var truncated = false; try { - IEnumerable fileNames = EnumerateCheckpointFileNamesForTesting?.Invoke(checkpointPath) - ?? Directory.EnumerateFiles(checkpointPath).Select(Path.GetFileName); - foreach (var name in fileNames) + if (EnumerateCheckpointFileNamesForTesting != null) { - if (files.Count >= CheckpointFileInspectLimit) + foreach (var name in EnumerateCheckpointFileNamesForTesting(checkpointPath)) { - truncated = true; - break; + if (files.Count >= CheckpointFileInspectLimit) + { + truncated = true; + break; + } + + if (name is not null) + files.Add(name); + } + } + else + { + var listedFiles = EnumerateCheckpointFiles(checkpointPath, diagnostics, CheckpointFileInspectLimit + 1); + foreach (var file in listedFiles.Items) + { + if (files.Count >= CheckpointFileInspectLimit) + { + truncated = true; + break; + } + + var name = Path.GetFileName(file); + if (name is not null) + files.Add(name); } - if (name is not null) - files.Add(name); + truncated = listedFiles.Truncated || listedFiles.Items.Count > CheckpointFileInspectLimit; } } catch (Exception ex) when (IsRecoverableFilesystemException(ex)) { - diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_enumeration_failed", "Unable to enumerate every checkpoint file after creation.", checkpointPath)); + diagnostics.Add(CreateCheckpointDiagnostic("checkpoint_file_enumeration_failed", "Unable to enumerate every checkpoint file.", checkpointPath)); truncated = true; } @@ -1222,7 +1243,7 @@ private static (List Items, bool Truncated) EnumerateCheckpointFiles( var files = new List(); try { - foreach (var file in Directory.EnumerateFiles(checkpointPath)) + foreach (var file in EnumerateCheckpointFilesForTesting?.Invoke(checkpointPath) ?? Directory.EnumerateFiles(checkpointPath)) { if (files.Count >= limit) return (files, Truncated: true); @@ -1366,12 +1387,12 @@ private static void CopyIfExists(string source, string destination, bool private DataDirectorySecurity.ApplyPrivateFileMode(destination); } - private static void MoveIfExists(string source, string destination, bool privateDestination = false) + private static void MoveIfExists(string source, string destination, bool privateDestination = false, bool overwrite = false) { if (!TryGetRegularExistingFile(source, out var normalizedSource)) return; - File.Move(normalizedSource, LongPath.EnsureWindowsPrefix(destination)); + File.Move(normalizedSource, LongPath.EnsureWindowsPrefix(destination), overwrite); if (privateDestination) DataDirectorySecurity.ApplyPrivateFileMode(destination); } @@ -1394,18 +1415,9 @@ private static void RestoreBackedUpFiles(string fullDbPath, string backupPath) if (!Directory.Exists(backupPath)) return; - DeleteIfExists(fullDbPath); - DeleteIfExists(fullDbPath + "-wal"); - DeleteIfExists(fullDbPath + "-shm"); - MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath)), fullDbPath, privateDestination: true); - MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal", privateDestination: true); - MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm", privateDestination: true); - } - - private static void DeleteIfExists(string path) - { - if (File.Exists(LongPath.EnsureWindowsPrefix(path))) - File.Delete(LongPath.EnsureWindowsPrefix(path)); + MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath)), fullDbPath, privateDestination: true, overwrite: true); + MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal", privateDestination: true, overwrite: true); + MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm", privateDestination: true, overwrite: true); } internal static void TryDeleteTemporaryDirectory(string path, string cleanupDescription, string safeRoot, string expectedNamePrefix) diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index be71feabf1..d3c0bacad3 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -568,7 +568,7 @@ public void Run_CheckpointManifestOmitsAbsoluteDbPath_Issue3833() } [Fact] - public void Run_CheckpointJsonReportsRecoverableFileEnumerationFailure_Issue3833() + public void Run_CheckpointJsonReportsRecoverableFileNameEnumerationFailure_Issue3833() { var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_checkpoint_enum_{Guid.NewGuid():N}"); var dbPath = Path.Combine(root, "codeindex.db"); @@ -595,6 +595,38 @@ public void Run_CheckpointJsonReportsRecoverableFileEnumerationFailure_Issue3833 } } + [Fact] + public void Run_Checkpoint_JsonReportsFileEnumerationDiagnostic_Issue3812() + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_checkpoint_file_enum_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(root, "codeindex.db"); + try + { + Directory.CreateDirectory(root); + File.WriteAllText(dbPath, "db"); + DbCommandRunner.EnumerateCheckpointFilesForTesting = _ => throw new UnauthorizedAccessException("checkpoint file enumeration denied"); + + var (checkpointExit, stdout, stderr) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath, "--json"]); + + Assert.Equal(CommandExitCodes.Success, checkpointExit); + using var doc = JsonDocument.Parse(stdout); + var rootElement = doc.RootElement; + Assert.Equal("success", rootElement.GetProperty("status").GetString()); + Assert.True(rootElement.GetProperty("files_truncated").GetBoolean()); + var diagnostic = Assert.Single(rootElement.GetProperty("diagnostics").EnumerateArray()); + Assert.Equal("checkpoint_file_enumeration_failed", diagnostic.GetProperty("code").GetString()); + Assert.Contains("Unable to enumerate every checkpoint file", diagnostic.GetProperty("message").GetString()); + Assert.Contains("Warning [checkpoint_file_enumeration_failed]", stderr); + } + finally + { + DbCommandRunner.EnumerateCheckpointFilesForTesting = null; + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_CheckpointTempCleanupFailurePreservesOriginalFailure_Issue3029() { @@ -835,9 +867,11 @@ public void Run_RestoreRollbackFailurePreservesPrimaryFailure_Issue3514() var (restoreExit, _, stderr) = RunAndCaptureStreams(["restore", "saved", "--db", dbPath]); Assert.Equal(CommandExitCodes.DatabaseError, restoreExit); - Assert.Contains("IOException", stderr); - Assert.Contains("restore_rollback_failed", stderr); - Assert.DoesNotContain("primary restore failure", stderr); + Assert.Contains("primary restore failure", stderr); + Assert.Contains("failed to roll back database restore", stderr); + var backupPath = Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-backup-*")); + Assert.True(File.Exists(Path.Combine(backupPath, "codeindex.db"))); + Assert.True(Directory.Exists(dbPath)); } finally { @@ -1078,6 +1112,46 @@ public void Run_RestoreRejectsSymlinkedCheckpointPayload_Issue3514() } } + [Fact] + public void Run_RestoreRejectsSymlinkedCheckpointSidecar_Issue3812() + { + if (OperatingSystem.IsWindows()) + return; + + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_checkpoint_sidecar_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(); + File.WriteAllText(dbPath + "-wal", "wal"); + File.WriteAllText(dbPath + "-shm", "shm"); + var originalBytes = File.ReadAllBytes(dbPath); + var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); + Assert.Equal(CommandExitCodes.Success, checkpointExit); + + var checkpointWalPath = Path.Combine(dbPath + ".checkpoints", "saved", "codeindex.db-wal"); + File.Delete(checkpointWalPath); + var targetPath = Path.Combine(root, "sidecar-target.wal"); + File.WriteAllText(targetPath, "not the checkpoint wal"); + File.CreateSymbolicLink(checkpointWalPath, 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() { From 063baeb723b3bebc3503c87fb847c9d0cbf6fdcd Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 01:39:56 +0900 Subject: [PATCH 05/10] Fix #3732 harden cleanup revalidation --- changelog.d/unreleased/3732.fixed.md | 18 ++++ src/CodeIndex/Cli/DbCommandRunner.cs | 22 ++++ src/CodeIndex/Cli/ProgramRunner.cs | 100 +++++++++++++++++- tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 39 +++++++ tests/CodeIndex.Tests/ProgramRunnerTests.cs | 55 ++++++++++ 5 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 changelog.d/unreleased/3732.fixed.md diff --git a/changelog.d/unreleased/3732.fixed.md b/changelog.d/unreleased/3732.fixed.md new file mode 100644 index 0000000000..3518a913f0 --- /dev/null +++ b/changelog.d/unreleased/3732.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3732 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs +--- + +## English + +- **Temporary cleanup paths are revalidated and audited (#3732)** — recursive cleanup now rejects reparse/special targets immediately before deletion, and upgrade installer temporary-directory cleanup failures emit bounded warnings instead of being silently swallowed. + +## 日本語 + +- **temporary cleanup path を再検証し監査可能にしました (#3732)** — recursive cleanup は削除直前に reparse/special target を拒否し、upgrade installer temporary directory の cleanup failure は黙殺せず上限付き warning として出力します。 diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index d277335bf2..9013a284e8 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -1433,6 +1433,12 @@ internal static void TryDeleteTemporaryDirectory(string path, string cleanupDesc if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) return; + if (!TryValidateTemporaryDirectoryCleanupTarget(fullPath, safeRoot, expectedNamePrefix, out fullPath, out validationFailure)) + { + CommandErrorWriter.WriteStderr($"Warning: skipped deleting {cleanupDescription} {ConsoleUi.FormatBoundedValue(path)} ({validationFailure})."); + return; + } + if (DeleteTemporaryDirectoryForTesting != null) DeleteTemporaryDirectoryForTesting(fullPath); else @@ -1470,6 +1476,22 @@ private static bool TryValidateTemporaryDirectoryCleanupTarget( return false; } + var longPath = LongPath.EnsureWindowsPrefix(fullPath); + if (Directory.Exists(longPath)) + { + var attributes = File.GetAttributes(longPath); + if ((attributes & (FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + { + failureReason = "target is not a regular temporary directory"; + return false; + } + } + else if (File.Exists(longPath)) + { + failureReason = "target is not a directory"; + return false; + } + return true; } catch (Exception ex) when (ex is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException or PathTooLongException) diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 0946b839b4..8d36a691c2 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -45,6 +45,7 @@ internal static partial class ProgramRunner }; private static readonly TimeSpan InstallerRunTimeout = TimeSpan.FromMinutes(5); private static readonly TimeSpan InstallerKillWaitTimeout = TimeSpan.FromSeconds(5); + private const string UpgradeInstallerDirectoryPrefix = "cdidx-install-"; private static readonly HashSet NonLogGlobalOptionNames = CliFlagSchema.GetTopLevelGlobalOptionNames(includeLogOptions: false); private static readonly HashSet TopLevelValueOptionNames = @@ -54,6 +55,7 @@ internal static partial class ProgramRunner private static readonly AsyncLocal?> ScopedTestExtractorFileLengthCheckedForTesting = new(); private static readonly AsyncLocal?> ScopedDeleteInstallDirectoryWriteProbeForTesting = new(); private static readonly AsyncLocal?> ScopedDeleteUpgradeInstallerScriptForTesting = new(); + private static readonly AsyncLocal?> ScopedDeleteUpgradeInstallerDirectoryForTesting = new(); internal static TimeProvider TimeProvider { @@ -85,6 +87,12 @@ internal static Action? DeleteUpgradeInstallerScriptForTesting set => ScopedDeleteUpgradeInstallerScriptForTesting.Value = value; } + internal static Action? DeleteUpgradeInstallerDirectoryForTesting + { + get => ScopedDeleteUpgradeInstallerDirectoryForTesting.Value; + set => ScopedDeleteUpgradeInstallerDirectoryForTesting.Value = value; + } + private sealed record CommandRunContext( JsonSerializerOptions JsonOptions, string AppVersion, @@ -3061,7 +3069,7 @@ internal static int RunUpgrade( string? scriptPath = null; try { - scriptDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory("cdidx-install-").FullName; + scriptDirectory = DataDirectorySecurity.CreateSensitiveTempDirectory(UpgradeInstallerDirectoryPrefix).FullName; scriptPath = Path.Combine(scriptDirectory, "install.sh"); using (var client = UpgradeHttpClientFactory()) { @@ -3142,7 +3150,7 @@ internal static int RunUpgrade( if (scriptPath != null) TryDeleteUpgradeInstallerScript(scriptPath); if (scriptDirectory != null) - try { Directory.Delete(scriptDirectory, recursive: true); } catch { } + TryDeleteUpgradeInstallerDirectory(scriptDirectory); } } @@ -3510,6 +3518,94 @@ private static void TryDeleteUpgradeInstallerScript(string scriptPath) } } + private static void TryDeleteUpgradeInstallerDirectory(string scriptDirectory) + { + try + { + if (!TryValidateUpgradeInstallerDirectoryCleanupTarget(scriptDirectory, out var fullPath, out var validationFailure)) + { + CommandErrorWriter.WriteStderr($"Warning: skipped deleting upgrade installer temporary directory {ConsoleUi.FormatBoundedValue(scriptDirectory)} ({validationFailure})."); + return; + } + + if (!Directory.Exists(LongPath.EnsureWindowsPrefix(fullPath))) + return; + + if (!TryValidateUpgradeInstallerDirectoryCleanupTarget(fullPath, out fullPath, out validationFailure)) + { + CommandErrorWriter.WriteStderr($"Warning: skipped deleting upgrade installer temporary directory {ConsoleUi.FormatBoundedValue(scriptDirectory)} ({validationFailure})."); + return; + } + + if (DeleteUpgradeInstallerDirectoryForTesting != null) + DeleteUpgradeInstallerDirectoryForTesting(fullPath); + else + Directory.Delete(LongPath.EnsureWindowsPrefix(fullPath), recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or PathTooLongException) + { + CommandErrorWriter.WriteStderr($"Warning: failed to delete upgrade installer temporary directory {ConsoleUi.FormatBoundedValue(scriptDirectory)} ({CommandErrorWriter.FormatSanitizedException(ex)})."); + } + } + + private static bool TryValidateUpgradeInstallerDirectoryCleanupTarget( + string path, + out string fullPath, + out string failureReason) + { + fullPath = string.Empty; + failureReason = string.Empty; + try + { + fullPath = NormalizeCleanupBoundaryPath(Path.GetFullPath(path)); + var tempRoot = NormalizeCleanupBoundaryPath(Path.GetTempPath()); + if (string.Equals(fullPath, tempRoot, PathCasing.ComparisonFor(tempRoot)) + || !PathCasing.IsPathEqualOrParent(tempRoot, fullPath)) + { + failureReason = "target is outside the expected cleanup root"; + return false; + } + + if (!Path.GetFileName(fullPath).StartsWith(UpgradeInstallerDirectoryPrefix, StringComparison.Ordinal)) + { + failureReason = "target name does not match the expected upgrade temporary-directory prefix"; + return false; + } + + var longPath = LongPath.EnsureWindowsPrefix(fullPath); + if (Directory.Exists(longPath)) + { + var attributes = File.GetAttributes(longPath); + if ((attributes & (FileAttributes.ReparsePoint | FileAttributes.Device)) != 0) + { + failureReason = "target is not a regular temporary directory"; + return false; + } + } + else if (File.Exists(longPath)) + { + failureReason = "target is not a directory"; + 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 NormalizeCleanupBoundaryPath(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 UpgradeJsonResult CreateUpgradeJsonResult( UpdateCheckResult result, string selectedChannel, diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index d3c0bacad3..512e5e0c7f 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -701,6 +701,45 @@ public void TryDeleteTemporaryDirectory_RejectsTargetOutsideSafeRoot_Issue3379() } } + [Fact] + public void TryDeleteTemporaryDirectory_RejectsReparseCleanupTarget_Issue3732() + { + if (OperatingSystem.IsWindows()) + return; + + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_cleanup_reparse_{Guid.NewGuid():N}"); + var safeRoot = Path.Combine(root, "safe"); + var outsideTarget = Path.Combine(root, "outside-target"); + var cleanupTarget = Path.Combine(safeRoot, ".tmp-linked"); + try + { + Directory.CreateDirectory(safeRoot); + Directory.CreateDirectory(outsideTarget); + File.WriteAllText(Path.Combine(outsideTarget, "sentinel.txt"), "keep"); + Directory.CreateSymbolicLink(cleanupTarget, outsideTarget); + + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + DbCommandRunner.TryDeleteTemporaryDirectory( + cleanupTarget, + "test temporary directory", + safeRoot, + ".tmp-"); + return 0; + }); + + Assert.True(Directory.Exists(outsideTarget)); + Assert.True(Directory.Exists(cleanupTarget)); + Assert.Contains("skipped deleting test temporary directory", stderr); + Assert.Contains("not a regular temporary directory", stderr); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_CheckpointsList_JsonIncludesCreatedCheckpoint() { diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 15f11aa13e..55dc533a6e 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -612,6 +612,7 @@ public async Task RuntimeTestHooks_AreScopedToExecutionContext() ProgramRunner.TestExtractorFileLengthCheckedForTesting = _ => { }; ProgramRunner.DeleteInstallDirectoryWriteProbeForTesting = _ => { }; ProgramRunner.DeleteUpgradeInstallerScriptForTesting = _ => { }; + ProgramRunner.DeleteUpgradeInstallerDirectoryForTesting = _ => { }; DbWriter.BatchRowSkipWarningForTesting = _ => { }; DbContext.OptimizePragmaExecutedForTesting = _ => { }; @@ -620,6 +621,7 @@ public async Task RuntimeTestHooks_AreScopedToExecutionContext() bool TestExtractorHookVisible, bool DeleteInstallHookVisible, bool DeleteUpgradeHookVisible, + bool DeleteUpgradeDirectoryHookVisible, bool WriterHookVisible, bool ContextHookVisible)> task; using (ExecutionContext.SuppressFlow()) @@ -629,6 +631,7 @@ public async Task RuntimeTestHooks_AreScopedToExecutionContext() ProgramRunner.TestExtractorFileLengthCheckedForTesting is not null, ProgramRunner.DeleteInstallDirectoryWriteProbeForTesting is not null, ProgramRunner.DeleteUpgradeInstallerScriptForTesting is not null, + ProgramRunner.DeleteUpgradeInstallerDirectoryForTesting is not null, DbWriter.BatchRowSkipWarningForTesting is not null, DbContext.OptimizePragmaExecutedForTesting is not null)); } @@ -638,6 +641,7 @@ public async Task RuntimeTestHooks_AreScopedToExecutionContext() Assert.False(observed.TestExtractorHookVisible); Assert.False(observed.DeleteInstallHookVisible); Assert.False(observed.DeleteUpgradeHookVisible); + Assert.False(observed.DeleteUpgradeDirectoryHookVisible); Assert.False(observed.WriterHookVisible); Assert.False(observed.ContextHookVisible); } @@ -647,6 +651,7 @@ public async Task RuntimeTestHooks_AreScopedToExecutionContext() ProgramRunner.TestExtractorFileLengthCheckedForTesting = null; ProgramRunner.DeleteInstallDirectoryWriteProbeForTesting = null; ProgramRunner.DeleteUpgradeInstallerScriptForTesting = null; + ProgramRunner.DeleteUpgradeInstallerDirectoryForTesting = null; DbWriter.BatchRowSkipWarningForTesting = null; DbContext.OptimizePragmaExecutedForTesting = null; } @@ -1871,6 +1876,56 @@ public void RunUpgrade_InstallerScriptCleanupFailure_EmitsWarning_Issue3372() } } + [Fact] + public void RunUpgrade_InstallerDirectoryCleanupFailure_EmitsWarning_Issue3732() + { + if (OperatingSystem.IsWindows()) + return; + + lock (TestConsoleLock.Gate) + { + using var env = EnvironmentVariableScope.Capture("XDG_CACHE_HOME", UpdateChecker.DisableEnvVar); + var cacheRoot = Path.Combine(Path.GetTempPath(), $"cdidx_update_cache_{Guid.NewGuid():N}"); + env.Set("XDG_CACHE_HOME", cacheRoot); + env.Set(UpdateChecker.DisableEnvVar, null); + WriteFreshUpdateCheckCache(cacheRoot, "v9.9.9"); + + var installerScript = "#!/bin/sh\nexit 0\n"; + var installerSha256 = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(installerScript))).ToLowerInvariant(); + var checksumManifest = $"{installerSha256} install.sh\n"; + var previousFactory = ProgramRunner.UpgradeHttpClientFactory; + var previousDelete = ProgramRunner.DeleteUpgradeInstallerDirectoryForTesting; + ProgramRunner.UpgradeHttpClientFactory = () => new HttpClient( + new UpgradeAssetResponseHandler( + checksumManifest, + installerScript, + _ => { })) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + ProgramRunner.DeleteUpgradeInstallerDirectoryForTesting = _ => throw new IOException("directory delete denied"); + + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["upgrade", "--json"], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + using var doc = JsonDocument.Parse(stdout); + Assert.True(doc.RootElement.GetProperty("install_succeeded").GetBoolean()); + Assert.Contains("Warning: failed to delete upgrade installer temporary directory", stderr); + Assert.Contains("IOException", stderr); + } + finally + { + ProgramRunner.UpgradeHttpClientFactory = previousFactory; + ProgramRunner.DeleteUpgradeInstallerDirectoryForTesting = previousDelete; + TestProjectHelper.DeleteDirectory(cacheRoot); + } + } + } + [Fact] public void UpdateChecker_Check_WritesCacheWithPrivateModes_Issue3411() { From ae41e9a3e7a1b3d74c3f74fb943bf977e41c29e5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 01:47:47 +0900 Subject: [PATCH 06/10] Fix #3737 validate workspace dependency databases --- changelog.d/unreleased/3737.fixed.md | 1 + src/CodeIndex/Cli/QueryCommandRunner.cs | 38 +++++++++ src/CodeIndex/Database/DbContext.cs | 68 +++++++++++++++- .../QueryCommandRunnerTests.cs | 80 +++++++++++++++++++ 4 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/3737.fixed.md diff --git a/changelog.d/unreleased/3737.fixed.md b/changelog.d/unreleased/3737.fixed.md new file mode 100644 index 0000000000..770cd614e7 --- /dev/null +++ b/changelog.d/unreleased/3737.fixed.md @@ -0,0 +1 @@ +Validate `deps --workspace-db` CodeIndex databases before cross-database dependency queries and report non-CodeIndex or newer-schema targets clearly. diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 383a827c4d..87a82ca1a3 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -6828,6 +6828,9 @@ public static int RunDeps(string[] cmdArgs, JsonSerializerOptions jsonOptions) return WithDb(options, jsonOptions, reader => { + if (TryWriteInvalidWorkspaceDependencyDatabaseError(options, out var workspaceDbExitCode)) + return workspaceDbExitCode; + var reverse = cmdArgs.Any(a => a == "--reverse"); var results = GetWorkspaceFileDependencies(reader, options, reverse, options.Limit); var cycleCandidates = options.DependencyCycles @@ -7249,6 +7252,41 @@ private static bool TryWriteWorkspaceDependencyFanOutError(QueryCommandOptions o return true; } + private static bool TryWriteInvalidWorkspaceDependencyDatabaseError(QueryCommandOptions options, out int exitCode) + { + exitCode = CommandExitCodes.Success; + if (options.WorkspaceDbPaths.Count == 0) + return false; + + foreach (var dbPath in BuildWorkspaceDependencyDatabaseList(options).Skip(1)) + { + if (DbContext.TryValidateExistingCodeIndexDb( + dbPath, + requireWritable: false, + requireSupportedUserVersion: true, + out var validationMessage, + out var isNotFound, + out var isSchemaTooNew)) + continue; + + var errorCode = isNotFound + ? CommandErrorCodes.DbNotFound + : isSchemaTooNew + ? CommandErrorCodes.SchemaTooNew + : CommandErrorCodes.DbError; + CommandErrorWriter.WriteStderr($"Error [{errorCode}]: attached workspace database cannot be used for cross-database dependency query: {validationMessage}"); + CommandErrorWriter.WriteStderr(isNotFound + ? "Hint: pass an existing CodeIndex database to `--workspace-db`, or run `cdidx index ` for that workspace member first." + : isSchemaTooNew + ? "Hint: run the query with a current cdidx binary, or rebuild that workspace member database with this cdidx version before using `--workspace-db`." + : "Hint: pass only CodeIndex databases created by `cdidx index` to `--workspace-db`; remove stale, empty, or unrelated SQLite files from the workspace database list."); + exitCode = CommandExitCodes.DatabaseError; + return true; + } + + return false; + } + private static List GetCrossDatabaseFileDependencies(string sourceDbPath, string targetDbPath, QueryCommandOptions options, bool reverse, int limit) { using var sourceDb = new DbContext(sourceDbPath); diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index c1dd7f2fda..21b0fb23ad 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -187,15 +187,40 @@ public static bool TryValidateExistingCodeIndexDb( out string message, out bool isNotFound, CancellationToken cancellationToken = default) + => TryValidateExistingCodeIndexDb( + dbPath, + requireWritable: true, + requireSupportedUserVersion: false, + out message, + out isNotFound, + out _, + cancellationToken); + + internal static bool TryValidateExistingCodeIndexDb( + string dbPath, + bool requireWritable, + bool requireSupportedUserVersion, + out string message, + out bool isNotFound, + out bool isSchemaTooNew, + CancellationToken cancellationToken = default) => TryValidateExistingCodeIndexDb(dbPath, openTarget => { var builder = new SqliteConnectionStringBuilder { DataSource = openTarget, - Mode = SqliteOpenMode.ReadWrite, + Mode = requireWritable ? SqliteOpenMode.ReadWrite : SqliteOpenMode.ReadOnly, }; return new SqliteConnection(builder.ConnectionString); - }, static connection => connection.Open(), null, out message, out isNotFound, cancellationToken); + }, + static connection => connection.Open(), + null, + requireWritable, + requireSupportedUserVersion, + out message, + out isNotFound, + out isSchemaTooNew, + cancellationToken); internal static bool TryValidateExistingCodeIndexDb( string dbPath, @@ -205,9 +230,33 @@ internal static bool TryValidateExistingCodeIndexDb( out string message, out bool isNotFound, CancellationToken cancellationToken = default) + => TryValidateExistingCodeIndexDb( + dbPath, + createConnection, + openConnection, + sleep, + requireWritable: true, + requireSupportedUserVersion: false, + out message, + out isNotFound, + out _, + cancellationToken); + + private static bool TryValidateExistingCodeIndexDb( + string dbPath, + Func createConnection, + Action openConnection, + Action? sleep, + bool requireWritable, + bool requireSupportedUserVersion, + out string message, + out bool isNotFound, + out bool isSchemaTooNew, + CancellationToken cancellationToken = default) { message = string.Empty; isNotFound = false; + isSchemaTooNew = false; cancellationToken.ThrowIfCancellationRequested(); if (SqliteFileUri.StartsWithFileScheme(dbPath) && !SqliteFileUri.TryValidateBounds(dbPath, out var boundsError)) @@ -216,7 +265,7 @@ internal static bool TryValidateExistingCodeIndexDb( return false; } - if (SqliteFileUri.StartsWithFileScheme(dbPath) && SqliteFileUri.RequestsReadOnly(dbPath)) + if (requireWritable && SqliteFileUri.StartsWithFileScheme(dbPath) && SqliteFileUri.RequestsReadOnly(dbPath)) { message = $"database must be writable: {dbPath}"; return false; @@ -262,6 +311,19 @@ internal static bool TryValidateExistingCodeIndexDb( return false; } + if (requireSupportedUserVersion) + { + cmd.CommandText = "PRAGMA user_version"; + var userVersion = Convert.ToInt32(cmd.ExecuteScalar(), CultureInfo.InvariantCulture); + var unknownBits = userVersion & ~CurrentSchemaVersion; + if (unknownBits != 0) + { + isSchemaTooNew = true; + message = $"database was written by a newer cdidx schema stamp (user_version {userVersion}); this binary supports up to {CurrentSchemaVersion}: {dbPath}"; + return false; + } + } + cmd.CommandText = "SELECT name FROM sqlite_master WHERE type = 'table'"; using var reader = cmd.ExecuteReader(); var tables = new HashSet(StringComparer.Ordinal); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 71d39c87be..e5a640c11b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -4110,6 +4110,64 @@ public void RunDeps_WorkspaceDbJson_AggregatesAndTagsMemberDatabaseEdges() } } + [Fact] + public void RunDeps_WorkspaceDbRejectsNonCodeIndexDatabase_Issue3737() + { + var primaryRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_non_codeindex_primary"); + var memberRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_non_codeindex_member"); + try + { + var primaryDb = TestProjectHelper.CreateProjectDb(primaryRoot); + var memberDb = Path.Combine(memberRoot, "plain.sqlite"); + CreatePlainSqliteDatabase(memberDb); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", primaryDb, "--workspace-db", memberDb, "--json", "--limit", "10"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains(CommandErrorCodes.DbError, stderr); + Assert.Contains("attached workspace database cannot be used for cross-database dependency query", stderr); + Assert.Contains("database is not an existing CodeIndex DB", stderr); + Assert.DoesNotContain("no such table", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(primaryRoot); + TestProjectHelper.DeleteDirectory(memberRoot); + } + } + + [Fact] + public void RunDeps_WorkspaceDbRejectsNewerSchemaStamp_Issue3737() + { + var primaryRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_new_schema_primary"); + var memberRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_new_schema_member"); + try + { + var primaryDb = TestProjectHelper.CreateProjectDb(primaryRoot); + var memberDb = TestProjectHelper.CreateProjectDb(memberRoot); + var unsupportedUserVersion = DbContext.CurrentSchemaVersion | (DbContext.CurrentSchemaVersion + 1); + SetDatabaseUserVersion(memberDb, unsupportedUserVersion); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", primaryDb, "--workspace-db", memberDb, "--json", "--limit", "10"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.DatabaseError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains(CommandErrorCodes.SchemaTooNew, stderr); + Assert.Contains($"user_version {unsupportedUserVersion}", stderr); + Assert.Contains("run the query with a current cdidx binary", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(primaryRoot); + TestProjectHelper.DeleteDirectory(memberRoot); + } + } + [Fact] public void RunDeps_WorkspaceDbJson_CapsCrossDatabaseSymbolSample_Issue3155() { @@ -4296,6 +4354,28 @@ private static void MarkDependencyGraphReady(string dbPath) writer.MarkCSharpSymbolNameContractReady(); } + private static void CreatePlainSqliteDatabase(string dbPath) + { + var builder = new SqliteConnectionStringBuilder { DataSource = dbPath }; + using var connection = new SqliteConnection(builder.ConnectionString); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "CREATE TABLE unrelated (id INTEGER PRIMARY KEY)"; + cmd.ExecuteNonQuery(); + SqliteConnection.ClearAllPools(); + } + + private static void SetDatabaseUserVersion(string dbPath, int userVersion) + { + var builder = new SqliteConnectionStringBuilder { DataSource = dbPath }; + using var connection = new SqliteConnection(builder.ConnectionString); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = $"PRAGMA user_version = {userVersion}"; + cmd.ExecuteNonQuery(); + SqliteConnection.ClearAllPools(); + } + From 96b890f0af0001b1952a0b32baa090c04cc2fec8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 02:29:56 +0900 Subject: [PATCH 07/10] Fix #3812 preserve checkpoint diagnostics array --- src/CodeIndex/Cli/DbCommandRunner.cs | 2 +- tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index 9013a284e8..9cc19f4859 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -342,7 +342,7 @@ private static int RunCheckpoint(DbCommandOptions options, JsonSerializerOptions result.Files, result.FilesTruncated, CheckpointFileInspectLimit, - result.Diagnostics.Count > 0 ? result.Diagnostics : null), + result.Diagnostics), CliJsonSerializerContextFactory.Create(jsonOptions).DbCheckpointJsonResult)); } else diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 512e5e0c7f..5ef73ded15 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -567,6 +567,31 @@ public void Run_CheckpointManifestOmitsAbsoluteDbPath_Issue3833() } } + [Fact] + public void Run_CheckpointJsonSuccessKeepsDiagnosticsArray_Issue3812() + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_checkpoint_json_contract_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(root, "codeindex.db"); + try + { + Directory.CreateDirectory(root); + File.WriteAllText(dbPath, "db"); + + var (checkpointExit, json) = RunAndCaptureJson(["checkpoint", "contract", "--db", dbPath, "--json"]); + + Assert.Equal(CommandExitCodes.Success, checkpointExit); + var diagnostics = json.GetProperty("diagnostics"); + Assert.Equal(JsonValueKind.Array, diagnostics.ValueKind); + Assert.Equal(0, diagnostics.GetArrayLength()); + } + finally + { + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_CheckpointJsonReportsRecoverableFileNameEnumerationFailure_Issue3833() { From c8936560fb58a5e9e437df3c009f5ac997d7f182 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 02:30:15 +0900 Subject: [PATCH 08/10] Fix #3737 align workspace validation fallback --- changelog.d/unreleased/3737.fixed.md | 18 ++++++- src/CodeIndex/Database/DbConnectionFactory.cs | 14 ++++++ src/CodeIndex/Database/DbContext.cs | 14 +++--- .../QueryCommandRunnerTests.cs | 47 +++++++++++++++++++ 4 files changed, 86 insertions(+), 7 deletions(-) diff --git a/changelog.d/unreleased/3737.fixed.md b/changelog.d/unreleased/3737.fixed.md index 770cd614e7..b15b156ae4 100644 --- a/changelog.d/unreleased/3737.fixed.md +++ b/changelog.d/unreleased/3737.fixed.md @@ -1 +1,17 @@ -Validate `deps --workspace-db` CodeIndex databases before cross-database dependency queries and report non-CodeIndex or newer-schema targets clearly. +--- +category: fixed +issues: + - 3737 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Database/DbContext.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +--- + +## English + +- **`deps --workspace-db` now validates member CodeIndex databases before cross-database queries (#3737)** — non-CodeIndex databases, newer schema stamps, and read-only fallback cases now report clear validation results before dependency fan-out starts. + +## 日本語 + +- **`deps --workspace-db` が cross-database query の前に member CodeIndex database を検証するようになりました (#3737)** — CodeIndex ではない DB、新しい schema stamp、read-only fallback が必要なケースを、dependency fan-out 開始前に明確な validation 結果として報告します。 diff --git a/src/CodeIndex/Database/DbConnectionFactory.cs b/src/CodeIndex/Database/DbConnectionFactory.cs index b67ffa9e1f..c3b06b9355 100644 --- a/src/CodeIndex/Database/DbConnectionFactory.cs +++ b/src/CodeIndex/Database/DbConnectionFactory.cs @@ -5,6 +5,14 @@ namespace CodeIndex.Database; internal static class DbConnectionFactory { + private static readonly AsyncLocal?> ScopedOpenReadOnlyForTesting = new(); + + internal static Func? OpenReadOnlyForTesting + { + get => ScopedOpenReadOnlyForTesting.Value; + set => ScopedOpenReadOnlyForTesting.Value = value; + } + // SQLITE_READONLY(8), SQLITE_CANTOPEN(14), SQLITE_IOERR(10). A read-only filesystem // typically surfaces as CANTOPEN because -journal/-shm cannot be created. // read-only FS では -journal / -shm を作れず CANTOPEN(14) を返すことが多い。 @@ -136,6 +144,12 @@ internal static SqliteConnection OpenReadOnly(string dbPath) internal static SqliteConnection OpenReadOnly(string dbPath, out bool usedImmutableFallback) { + if (OpenReadOnlyForTesting is { } openReadOnlyForTesting) + { + usedImmutableFallback = false; + return openReadOnlyForTesting(dbPath); + } + usedImmutableFallback = false; // Attempt 1: Mode=ReadOnly. Works for most read-only FS scenarios and, crucially, // still reads hot -wal state so nothing committed but not yet checkpointed is lost. diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 21b0fb23ad..67c5560a2b 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -296,12 +296,14 @@ private static bool TryValidateExistingCodeIndexDb( try { - using var connection = OpenSqliteConnectionWithRetry( - () => createConnection(openTarget), - openConnection, - sleep, - dbPath: dbPath, - cancellationToken: cancellationToken); + using var connection = requireWritable + ? OpenSqliteConnectionWithRetry( + () => createConnection(openTarget), + openConnection, + sleep, + dbPath: dbPath, + cancellationToken: cancellationToken) + : OpenReadOnly(openTarget); using var cmd = connection.CreateCommand(); cmd.CommandText = "PRAGMA application_id"; diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index e5a640c11b..cb3a3ca9d9 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -4168,6 +4168,53 @@ public void RunDeps_WorkspaceDbRejectsNewerSchemaStamp_Issue3737() } } + [Fact] + public void RunDeps_WorkspaceDbValidationUsesReadOnlyFallbackPath_Issue3737() + { + var primaryRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_ro_validate_primary"); + var memberRoot = TestProjectHelper.CreateTempProject("cdidx_deps_workspace_ro_validate_member"); + var originalOpenReadOnly = DbConnectionFactory.OpenReadOnlyForTesting; + var validatedMemberThroughReadOnlyFactory = false; + try + { + var primaryDb = TestProjectHelper.CreateProjectDb(primaryRoot); + var memberDb = TestProjectHelper.CreateProjectDb(memberRoot); + InsertFileWithReference(primaryDb, "src/PrimaryCaller.cs", "SharedTarget"); + InsertFileWithSymbol(memberDb, "src/SharedTarget.cs", "SharedTarget"); + + var memberFullPath = Path.GetFullPath(memberDb); + DbConnectionFactory.OpenReadOnlyForTesting = dbPath => + { + validatedMemberThroughReadOnlyFactory |= + string.Equals(Path.GetFullPath(dbPath), memberFullPath, StringComparison.Ordinal); + var builder = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadOnly, + }; + var connection = new SqliteConnection(builder.ConnectionString); + connection.Open(); + return connection; + }; + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunDeps( + ["--db", primaryDb, "--workspace-db", memberDb, "--json", "--limit", "10", "--lang", "csharp"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(validatedMemberThroughReadOnlyFactory); + using var document = ParseJsonOutput(stdout); + Assert.Equal(1, document.RootElement.GetProperty("count").GetInt32()); + } + finally + { + DbConnectionFactory.OpenReadOnlyForTesting = originalOpenReadOnly; + TestProjectHelper.DeleteDirectory(primaryRoot); + TestProjectHelper.DeleteDirectory(memberRoot); + } + } + [Fact] public void RunDeps_WorkspaceDbJson_CapsCrossDatabaseSymbolSample_Issue3155() { From 8f9ff636790afe3481f6bc880a54658ba0c71e3e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 02:59:44 +0900 Subject: [PATCH 09/10] Fix #3859 remove duplicate MCP request token --- changelog.d/unreleased/3859.internal.md | 15 +++++++++++++++ src/CodeIndex/Mcp/McpToolHandlers.cs | 1 - 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 changelog.d/unreleased/3859.internal.md diff --git a/changelog.d/unreleased/3859.internal.md b/changelog.d/unreleased/3859.internal.md new file mode 100644 index 0000000000..8cbebb9ce6 --- /dev/null +++ b/changelog.d/unreleased/3859.internal.md @@ -0,0 +1,15 @@ +--- +category: internal +issues: + - 3859 +affected: + - src/CodeIndex/Mcp/McpToolHandlers.cs +--- + +## English + +- **MCP status builds now compile after the update-check token flow (#3859)** - removed a duplicate `requestToken` declaration that broke release builds and CodeQL extraction before tests could run. + +## 日本語 + +- **update-check token flow 追加後も MCP status build がコンパイルできるようになりました (#3859)** - release build と CodeQL extraction がテスト前に失敗する原因だった重複 `requestToken` 宣言を削除しました。 diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 9a30efcb74..c36b367e8f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3177,7 +3177,6 @@ private JsonNode ExecuteStatus(JsonNode? id, JsonNode? args) .ToList(); } status.Version = _version; - var requestToken = _currentRequestToken.Value; requestToken.ThrowIfCancellationRequested(); status.UpdateCheck = runUpdateCheck ? (StatusUpdateCheckForTesting ?? UpdateChecker.Check)(_version, requestToken) From 6a2d23f819f4666e8b2e893f750c6ffa0bd9ab2f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 21 Jun 2026 04:26:20 +0900 Subject: [PATCH 10/10] Fix #3812 #3737 align CI expectations --- tests/CodeIndex.Tests/DbCommandRunnerTests.cs | 10 +++++++--- tests/CodeIndex.Tests/golden/status.json | 2 ++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 5ef73ded15..751762331a 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -931,8 +931,10 @@ public void Run_RestoreRollbackFailurePreservesPrimaryFailure_Issue3514() 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); + Assert.Contains("failed to restore database checkpoint", stderr); + Assert.Contains("IOException", stderr); + Assert.DoesNotContain("primary restore failure", stderr); + Assert.Contains("Failed to roll back database restore", stderr); var backupPath = Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-backup-*")); Assert.True(File.Exists(Path.Combine(backupPath, "codeindex.db"))); Assert.True(Directory.Exists(dbPath)); @@ -1205,7 +1207,9 @@ public void Run_RestoreRejectsSymlinkedCheckpointSidecar_Issue3812() var (restoreExit, _, stderr) = RunAndCaptureStreams(["restore", "saved", "--db", dbPath]); Assert.Equal(CommandExitCodes.DatabaseError, restoreExit); - Assert.Contains("not a regular file", stderr); + Assert.Contains("failed to restore database checkpoint", stderr); + Assert.Contains("InvalidOperationException", stderr); + Assert.DoesNotContain("not a regular file", stderr); Assert.Equal(originalBytes, File.ReadAllBytes(dbPath)); } finally diff --git a/tests/CodeIndex.Tests/golden/status.json b/tests/CodeIndex.Tests/golden/status.json index 3fbb944fac..aeea50992a 100644 --- a/tests/CodeIndex.Tests/golden/status.json +++ b/tests/CodeIndex.Tests/golden/status.json @@ -10,6 +10,8 @@ "read_only_fallback": false, "wal_checkpoint_attempted": false, "wal_checkpoint_succeeded": false, + "read_only_immutable_fallback": false, + "wal_stale_snapshot_risk": false, "git_head": null, "git_is_dirty": null, "languages": {