From 49803218b235d68bb976c408ff166618165833de Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 30 May 2026 17:51:06 +0900 Subject: [PATCH 1/3] Fix read-only SQLite handling (#1730 #1798 #1794) --- DEVELOPER_GUIDE.md | 22 +++ changelog.d/unreleased/1730.added.md | 17 ++ changelog.d/unreleased/1794.security.md | 17 ++ changelog.d/unreleased/1798.fixed.md | 18 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 9 + src/CodeIndex/Cli/IndexCommandRunner.Parse.cs | 8 + src/CodeIndex/Cli/IndexCommandRunner.cs | 11 ++ src/CodeIndex/Cli/QueryCommandRunner.cs | 13 +- src/CodeIndex/Database/DbContext.cs | 157 +++++++++++++++++- .../Database/DbReader.FilesStatus.cs | 3 + src/CodeIndex/Database/DbReader.cs | 26 ++- src/CodeIndex/Models/QueryResults.cs | 9 + tests/CodeIndex.Tests/DatabaseTests.cs | 9 + .../IndexCommandRunnerTests.cs | 22 +++ .../QueryCommandRunnerTests.cs | 37 +++++ tests/CodeIndex.Tests/golden/status.json | 4 + 16 files changed, 370 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/1730.added.md create mode 100644 changelog.d/unreleased/1794.security.md create mode 100644 changelog.d/unreleased/1798.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index abed5af659..6ceeba8f66 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -17,6 +17,28 @@ target frameworks when validating the full CI-equivalent test matrix. For test suite structure, shared helpers, and test-writing conventions, see [TESTING_GUIDE.md](TESTING_GUIDE.md). +## CI / Artifact Distribution + +Query commands accept `--read-only` (alias `--immutable`) to open an existing +CodeIndex database through SQLite's immutable read-only URI mode. Use this for +CI artifacts, mounted caches, and sandboxes where creating or updating +`codeindex.db-wal` / `codeindex.db-shm` sidecars is not allowed: + +```bash +cdidx status --db /artifacts/codeindex.db --read-only --json +cdidx search AuthService --db /artifacts/codeindex.db --immutable +``` + +Mutating commands such as `index`, `backfill-fold`, `optimize`, and `vacuum` +require writable storage and reject read-only database opens. + +## Filesystem Permissions + +On POSIX filesystems, cdidx creates `.cdidx/` with mode `0700` and applies mode +`0600` to `codeindex.db` plus WAL/SHM sidecars when they exist. `status --json` +reports `data_dir_mode` and `db_file_mode` when the platform exposes Unix file +modes. + ## Release Distribution Checklist When preparing a release, verify every supported distribution channel documented diff --git a/changelog.d/unreleased/1730.added.md b/changelog.d/unreleased/1730.added.md new file mode 100644 index 0000000000..4fda46d50e --- /dev/null +++ b/changelog.d/unreleased/1730.added.md @@ -0,0 +1,17 @@ +--- +category: added +issues: + - 1730 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Database/DbContext.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Added read-only database opens for query commands (#1730)** — query commands now accept `--read-only` / `--immutable` and translate normal database paths into SQLite immutable read-only URIs. + +## 日本語 + +- **クエリコマンドで読み取り専用 DB オープンを追加しました (#1730)** — クエリコマンドは `--read-only` / `--immutable` を受け付け、通常の DB パスを SQLite の immutable read-only URI に変換します。 diff --git a/changelog.d/unreleased/1794.security.md b/changelog.d/unreleased/1794.security.md new file mode 100644 index 0000000000..722b5b3a4a --- /dev/null +++ b/changelog.d/unreleased/1794.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 1794 +affected: + - src/CodeIndex/Database/DbContext.cs + - src/CodeIndex/Models/QueryResults.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Restricted SQLite database file permissions on POSIX (#1794)** — cdidx now applies `0600` to `codeindex.db` and WAL/SHM sidecars and reports `db_file_mode` in `status --json`. + +## 日本語 + +- **POSIX で SQLite DB ファイル権限を制限しました (#1794)** — cdidx は `codeindex.db` と WAL/SHM sidecar に `0600` を適用し、`status --json` に `db_file_mode` を出します。 diff --git a/changelog.d/unreleased/1798.fixed.md b/changelog.d/unreleased/1798.fixed.md new file mode 100644 index 0000000000..53f6275360 --- /dev/null +++ b/changelog.d/unreleased/1798.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 1798 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.cs + - src/CodeIndex/Database/DbContext.cs + - src/CodeIndex/Database/DbReader.FilesStatus.cs + - src/CodeIndex/Models/QueryResults.cs +--- + +## English + +- **Checkpoint WAL before read-only fallback (#1798)** — writable-open fallback now attempts `wal_checkpoint(TRUNCATE)` first and exposes fallback/checkpoint diagnostics in `status --json`. + +## 日本語 + +- **読み取り専用フォールバック前に WAL checkpoint を試みるようにしました (#1798)** — writable open のフォールバック前に `wal_checkpoint(TRUNCATE)` を試行し、fallback / checkpoint 診断を `status --json` に出します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index ed4ab36d50..8501977961 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -164,6 +164,13 @@ internal static class CliFlagSchema "validate", "deps", "impact", "unused", "hotspots", "batch", ]; + private static readonly string[] ReadOnlyDbCommands = + [ + "search", "definition", "goto", "references", "callers", "callees", + "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", + "validate", "deps", "impact", "unused", "hotspots", + ]; + private static readonly string[] JsonCommands = [ "index", "backfill-fold", "optimize", "vacuum", "search", "definition", "goto", "references", "callers", "callees", @@ -193,6 +200,8 @@ private static IReadOnlyList BuildAll() return new List { new() { Name = "--db", ValuePlaceholder = "", Description = "Database path", Commands = Set(DbPathCommands) }, + new() { Name = "--read-only", Description = "Open the query database as immutable read-only storage", Commands = Set(ReadOnlyDbCommands) }, + new() { Name = "--immutable", Description = "Alias for --read-only", Commands = Set(ReadOnlyDbCommands) }, new() { Name = "--workspace-db", ValuePlaceholder = "", Description = "Additional workspace member database path for dependency aggregation", Commands = Set(WorkspaceDbCommands) }, new() { Name = "--data-dir", ValuePlaceholder = "", Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", Commands = Set(DataDirCommands) }, new() { Name = "--json", Description = "JSON output; search also accepts --json=array for a single JSON array", Commands = Set(JsonCommands) }, diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs index 065eb309b9..2d526f816b 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Parse.cs @@ -17,6 +17,7 @@ public static partial class IndexCommandRunner "--parallelism", "--commits", "--changed-between", "--files", "--solution", "--project", "--include-symbol-kind", "--exclude-symbol-kind", "--optimize", "--help", + "--read-only", "--immutable", ]; internal const string IndexParallelismEnvironmentVariable = "CDIDX_INDEX_PARALLELISM"; @@ -32,6 +33,7 @@ public static IndexCommandOptions ParseArgs(string[] args) bool quiet = false; bool dryRun = false; bool force = false; + bool readOnly = false; bool yes = false; bool watch = false; bool optimizeOnly = false; @@ -98,6 +100,11 @@ public static IndexCommandOptions ParseArgs(string[] args) case "--force": force = true; break; + case "--read-only": + case "--immutable": + readOnly = true; + parseError ??= $"{args[i]} is only supported by query commands; index mutates the database and cannot run read-only"; + break; case "--yes": yes = true; break; @@ -274,6 +281,7 @@ public static IndexCommandOptions ParseArgs(string[] args) EasterEgg = easterEgg, DryRun = dryRun, Force = force, + ReadOnly = readOnly, Yes = yes, Watch = watch, OptimizeOnly = optimizeOnly, diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 5d29c8fc31..49789d0640 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -178,6 +178,16 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C using (indexLock) { using var db = new DbContext(dbPath); + if (db.ReadOnlyFallback) + { + return WriteCommandError( + options.Json, + jsonOptions, + "database opened through stale read-only fallback after WAL checkpoint failed; index requires a writable database", + CommandExitCodes.DatabaseError, + "Move the database to writable storage, stop the writer holding the WAL lock, or rerun the query command with --read-only if you only need read access.", + CommandErrorCodes.DbNotWritable); + } // Capture prior readiness BEFORE we clear it. Update mode (--commits / --files) only // touches a subset of files, so trust bits the DB did NOT previously carry must not @@ -1154,6 +1164,7 @@ public sealed class IndexCommandOptions public string? EasterEgg { get; init; } public bool DryRun { get; init; } public bool Force { get; init; } + public bool ReadOnly { get; init; } public bool Yes { get; init; } public bool Watch { get; init; } public bool OptimizeOnly { get; init; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index a86e79975a..77e4a6a422 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -179,6 +179,8 @@ private sealed record StatusReadinessField( "--bytes", "--profile", "--check-updates", + "--read-only", + "--immutable", ]; private const string OutputFormatText = "text"; private const string OutputFormatJson = "json"; @@ -2614,6 +2616,7 @@ public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, status.DataDir = options.DataDir; status.DataDirSource = options.DataDirSource; status.DataDirMode = DataDirectorySecurity.GetUnixModeString(GetDataDirectoryPath(options.DbPath)); + status.DbFileMode = DbContext.GetUnixFileModeString(options.DbPath); status.MacProfile = MacProfileDetector.DetectCurrent(); if (options.CheckWorkspace) { @@ -4404,6 +4407,7 @@ public static QueryCommandOptions ParseArgs( bool exactName = false; bool exactSubstring = false; bool dbPathExplicit = false; + bool readOnly = false; bool checkWorkspace = false; TimeSpan? staleAfter = null; HashSet? statusCheckScopes = null; @@ -4521,6 +4525,10 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(dbPathError!); break; + case "--read-only": + case "--immutable": + readOnly = true; + break; case "--workspace-db": if (TryReadStringOptionValue(args, ref i, "--workspace-db", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var workspaceDbPath, out var workspaceDbError)) workspaceDbPaths.Add(workspaceDbPath!); @@ -5041,11 +5049,13 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) AddParseError(defaultMaxLineWidthError); var dbResolution = DbPathResolver.ResolveForQuery(Environment.CurrentDirectory, dbPath, dataDir); + var resolvedDbPath = readOnly ? DbContext.ToReadOnlyUri(dbResolution.DbPath) : dbResolution.DbPath; return new QueryCommandOptions { - DbPath = dbResolution.DbPath, + DbPath = resolvedDbPath, DbPathExplicit = dbPathExplicit, + ReadOnly = readOnly, DataDir = dbResolution.DataDir, DataDirSource = dbResolution.DataDirSource, Json = json ?? jsonDefault, @@ -7596,6 +7606,7 @@ public sealed class QueryCommandOptions { public string DbPath { get; init; } = Path.Combine(".cdidx", "codeindex.db"); public bool DbPathExplicit { get; init; } + public bool ReadOnly { get; init; } public string? DataDir { get; init; } public string? DataDirSource { get; init; } public bool Json { get; init; } diff --git a/src/CodeIndex/Database/DbContext.cs b/src/CodeIndex/Database/DbContext.cs index 533a54e043..8d9b18e2cb 100644 --- a/src/CodeIndex/Database/DbContext.cs +++ b/src/CodeIndex/Database/DbContext.cs @@ -28,8 +28,11 @@ public class DbContext : IDisposable "symbols", ]; - private readonly SqliteConnection _connection; - private readonly bool _isReadOnly; + private SqliteConnection _connection = null!; + private bool _isReadOnly; + private bool _readOnlyFallback; + private bool _walCheckpointAttempted; + private bool _walCheckpointSucceeded; private readonly string? _schemaCacheKey; private SqliteTransaction? _activeMigrationTransaction; private bool _readMigrationInsideExternalTransaction; @@ -44,6 +47,9 @@ public class DbContext : IDisposable public SqliteConnection Connection => _connection; public bool IsReadOnly => _isReadOnly; + public bool ReadOnlyFallback => _readOnlyFallback; + public bool WalCheckpointAttempted => _walCheckpointAttempted; + public bool WalCheckpointSucceeded => _walCheckpointSucceeded; public static string GetSymbolExtractorVersionMetaKey(string lang) => SymbolExtractorVersionMetaPrefix + lang; @@ -232,6 +238,7 @@ public DbContext(string dbPath) EnsureWritableUserVersionSupported(dbPath); ConfigureAutoVacuumForEmptyDatabase(); Execute($"PRAGMA application_id={ApplicationId}"); + ApplyPrivateDatabaseFileModes(dbPath); // Enable WAL mode and verify it was applied / WALモードを有効にし適用を確認 var journalMode = ExecuteScalar("PRAGMA journal_mode=WAL"); @@ -239,6 +246,7 @@ public DbContext(string dbPath) Console.Error.WriteLine($"Warning: WAL mode not enabled (got '{journalMode}')"); ExecuteSynchronousPragmaWithFallback(Execute); Execute($"PRAGMA wal_autocheckpoint={DefaultWalAutocheckpointPages}"); + ApplyPrivateDatabaseFileModes(dbPath); Execute("PRAGMA optimize=0x10002"); WarnIfBatchInProgress(); } @@ -253,14 +261,50 @@ public DbContext(string dbPath) // read-only FS / サンドボックスでも縮退 read path を動かせるようフォールバック。 // immutable=1 を付けないと SQLite は -shm/-wal を触ろうとして CANTOPEN で落ちることがある。 _connection?.Dispose(); + _walCheckpointAttempted = true; + _walCheckpointSucceeded = TryCheckpointWalBeforeReadOnlyFallback(dbPath); + if (_walCheckpointSucceeded) + { + try + { + _connection = OpenSqliteConnectionWithRetry( + () => new SqliteConnection(builder.ConnectionString), + static connection => connection.Open(), + static milliseconds => System.Threading.Thread.Sleep(milliseconds), + dbPath: dbPath); + Execute("PRAGMA busy_timeout=5000"); + ApplyConnectionPerformancePragmas(); + RegisterConnectionFunctionsWithRetry(_connection); + EnsureWritableUserVersionSupported(dbPath); + ConfigureAutoVacuumForEmptyDatabase(); + Execute($"PRAGMA application_id={ApplicationId}"); + ApplyPrivateDatabaseFileModes(dbPath); + var journalMode = ExecuteScalar("PRAGMA journal_mode=WAL"); + if (!string.Equals(journalMode, "wal", StringComparison.OrdinalIgnoreCase)) + Console.Error.WriteLine($"Warning: WAL mode not enabled (got '{journalMode}')"); + ExecuteSynchronousPragmaWithFallback(Execute); + Execute($"PRAGMA wal_autocheckpoint={DefaultWalAutocheckpointPages}"); + ApplyPrivateDatabaseFileModes(dbPath); + Execute("PRAGMA optimize=0x10002"); + WarnIfBatchInProgress(); + } + catch (SqliteException retryEx) when (IsReadOnlyOpenError(retryEx)) + { + _connection?.Dispose(); + _readOnlyFallback = true; + OpenReadOnlyFallback(dbPath); + } + + if (!_isReadOnly) + EnsureForeignKeysEnabled(); + _suppressWriteWorkTracking = false; + return; + } + try { - _connection = OpenReadOnly(dbPath); - Execute("PRAGMA busy_timeout=5000"); - ApplyConnectionPerformancePragmas(); - RegisterConnectionFunctionsWithRetry(_connection); - _isReadOnly = true; - WarnIfBatchInProgress(); + _readOnlyFallback = true; + OpenReadOnlyFallback(dbPath); } catch { @@ -282,6 +326,103 @@ public DbContext(string dbPath) _suppressWriteWorkTracking = false; } + private void OpenReadOnlyFallback(string dbPath) + { + _connection = OpenReadOnly(dbPath); + Execute("PRAGMA busy_timeout=5000"); + ApplyConnectionPerformancePragmas(); + RegisterConnectionFunctionsWithRetry(_connection); + _isReadOnly = true; + WarnIfBatchInProgress(); + } + + private static bool TryCheckpointWalBeforeReadOnlyFallback(string dbPath) + { + try + { + var builder = new SqliteConnectionStringBuilder + { + DataSource = dbPath, + Mode = SqliteOpenMode.ReadWrite, + }; + using var connection = OpenSqliteConnectionWithRetry( + () => new SqliteConnection(builder.ConnectionString), + static connection => connection.Open(), + static milliseconds => System.Threading.Thread.Sleep(milliseconds), + maxOpenAttempts: 1, + dbPath: dbPath); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE)"; + cmd.ExecuteNonQuery(); + return true; + } + catch (Exception ex) when (ex is SqliteException or CodeIndexException) + { + return false; + } + } + + public static string ToReadOnlyUri(string dbPath) + { + if (dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + return AppendReadOnlyQuery(dbPath); + + var fileUri = new Uri(Path.GetFullPath(dbPath)).AbsoluteUri; + return $"{fileUri}?immutable=1&mode=ro"; + } + + private static string AppendReadOnlyQuery(string uriText) + { + var separator = uriText.Contains('?', StringComparison.Ordinal) ? "&" : "?"; + var result = uriText; + if (!uriText.Contains("immutable=1", StringComparison.OrdinalIgnoreCase)) + { + result += $"{separator}immutable=1"; + separator = "&"; + } + if (!uriText.Contains("mode=ro", StringComparison.OrdinalIgnoreCase)) + result += $"{separator}mode=ro"; + return result; + } + + private static void ApplyPrivateDatabaseFileModes(string dbPath) + { + if (OperatingSystem.IsWindows() || dbPath.StartsWith("file:", StringComparison.OrdinalIgnoreCase)) + return; + + ApplyPrivateFileModeIfExists(dbPath); + ApplyPrivateFileModeIfExists(dbPath + "-wal"); + ApplyPrivateFileModeIfExists(dbPath + "-shm"); + } + + private static void ApplyPrivateFileModeIfExists(string path) + { + var normalizedPath = LongPath.EnsureWindowsPrefix(path); + if (!File.Exists(normalizedPath)) + return; + +#pragma warning disable CA1416 + File.SetUnixFileMode(normalizedPath, UnixFileMode.UserRead | UnixFileMode.UserWrite); +#pragma warning restore CA1416 + } + + public static string? GetUnixFileModeString(string? path) + { + if (string.IsNullOrWhiteSpace(path) || + OperatingSystem.IsWindows() || + path.StartsWith("file:", StringComparison.OrdinalIgnoreCase) || + !File.Exists(path)) + { + return null; + } + + var mode = File.GetUnixFileMode(path) & + (UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute); + return Convert.ToString((int)mode, 8).PadLeft(4, '0'); + } + private static string? TryCreateSchemaCacheKey(string dbPath) { if (string.IsNullOrWhiteSpace(dbPath)) diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index c53274c6ff..0fe42a6049 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -480,6 +480,9 @@ public StatusResult GetStatus() IndexNewerThanReaderReason = _indexNewerThanReaderReason, PathCaseSensitive = pathCaseSensitive, DbPragmaSettings = dbPragmaSettings, + ReadOnlyFallback = _readOnlyFallback, + WalCheckpointAttempted = _walCheckpointAttempted, + WalCheckpointSucceeded = _walCheckpointSucceeded, }; // 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 12eaa374df..33d3e13cb7 100644 --- a/src/CodeIndex/Database/DbReader.cs +++ b/src/CodeIndex/Database/DbReader.cs @@ -41,6 +41,9 @@ public partial class DbReader private readonly SqliteConnection _conn; private readonly PreparedCommandCache? _commandCache; private readonly bool _isReadOnly; + private readonly bool _readOnlyFallback; + private readonly bool _walCheckpointAttempted; + private readonly bool _walCheckpointSucceeded; private readonly DbSchemaCache? _schemaCache; private readonly CancellationToken _cancellation; private readonly HashSet _fileColumns; @@ -418,7 +421,10 @@ public DbReader(DbContext context) context.IsReadOnly, context.SchemaCache, CancellationToken.None, - context.PreparedCommands) + context.PreparedCommands, + context.ReadOnlyFallback, + context.WalCheckpointAttempted, + context.WalCheckpointSucceeded) { } @@ -433,7 +439,10 @@ public DbReader(DbContext context, CancellationToken cancellation) context.IsReadOnly, context.SchemaCache, cancellation, - context.PreparedCommands) + context.PreparedCommands, + context.ReadOnlyFallback, + context.WalCheckpointAttempted, + context.WalCheckpointSucceeded) { } @@ -459,7 +468,15 @@ public DbReader(SqliteConnection connection, bool isReadOnly, DbSchemaCache? sch { } - private DbReader(SqliteConnection connection, bool isReadOnly, DbSchemaCache? schemaCache, CancellationToken cancellation, PreparedCommandCache? commandCache) + private DbReader( + SqliteConnection connection, + bool isReadOnly, + DbSchemaCache? schemaCache, + CancellationToken cancellation, + PreparedCommandCache? commandCache, + bool readOnlyFallback = false, + bool walCheckpointAttempted = false, + bool walCheckpointSucceeded = false) { _conn = connection; _commandCache = commandCache; @@ -469,6 +486,9 @@ private DbReader(SqliteConnection connection, bool isReadOnly, DbSchemaCache? sc // SQL ユーザー関数は接続オープン時に `DbContext` が一度だけ登録するため、 // ここでの再登録は不要 (#1564)。 _isReadOnly = isReadOnly; + _readOnlyFallback = readOnlyFallback; + _walCheckpointAttempted = walCheckpointAttempted; + _walCheckpointSucceeded = walCheckpointSucceeded; _schemaCache = schemaCache; _cancellation = cancellation; _fileColumns = LoadColumns("files"); diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 41f6ca2888..87eb58241b 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -478,6 +478,15 @@ public class StatusResult [JsonPropertyName("data_dir_mode")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? DataDirMode { get; set; } + [JsonPropertyName("db_file_mode")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? DbFileMode { get; set; } + [JsonPropertyName("read_only_fallback")] + public bool ReadOnlyFallback { get; set; } + [JsonPropertyName("wal_checkpoint_attempted")] + public bool WalCheckpointAttempted { get; set; } + [JsonPropertyName("wal_checkpoint_succeeded")] + public bool WalCheckpointSucceeded { get; set; } public string? GitHead { get; set; } public bool? GitIsDirty { get; set; } /// diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index 494d13a9a2..2a0df4d06f 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -2227,6 +2227,15 @@ public void Dispose() DeleteDbPath(); } + [Fact] + public void DbContext_NewDatabaseRestrictsFileModeOnPosix() + { + if (OperatingSystem.IsWindows()) + return; + + Assert.Equal("0600", DbContext.GetUnixFileModeString(_dbPath)); + } + private void DeleteDbPath() { DeleteDbFiles(_dbPath); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 1328b3d0ae..0e027c9e0f 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -9046,4 +9046,26 @@ public void Run_DryRun_DoesNotAcquireLock() File.Delete(lockPath); } } + + [Fact] + public void Run_ReadOnlyFlag_ReturnsUsageError() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_index_readonly_{Guid.NewGuid():N}.db"); + try + { + File.WriteAllText(Path.Combine(projectRoot, "app.py"), "print('hi')\n"); + + var (exitCode, json) = RunAndCaptureJson([projectRoot, "--db", dbPath, "--read-only", "--json"]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("query commands", json.GetProperty("message").GetString(), StringComparison.Ordinal); + } + finally + { + DeleteDirectory(projectRoot); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 2879bc0753..83dfd4c5c4 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -33837,6 +33837,43 @@ public void RunFiles_JsonOutputKeepsRawSizeInteger() } } + [Fact] + public void RunStatus_ReadOnlyFlagOpensImmutableUriAndReportsModeFields() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_status_readonly"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/app.cs", "csharp", "class App {}\n"); + + var options = QueryCommandRunner.ParseArgs( + ["--db", dbPath, "--read-only", "--json"], + jsonDefault: false, + allowStatusCheck: true, + validateDefaultLimit: false, + validateDefaultSnippetLines: false, + validateDefaultMaxLineWidth: false); + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunStatus( + ["--db", dbPath, "--read-only", "--json"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.StartsWith("file:", options.DbPath, StringComparison.OrdinalIgnoreCase); + Assert.Contains("immutable=1", options.DbPath, StringComparison.OrdinalIgnoreCase); + Assert.Contains("mode=ro", options.DbPath, StringComparison.OrdinalIgnoreCase); + Assert.False(document.RootElement.GetProperty("read_only_fallback").GetBoolean()); + Assert.False(document.RootElement.GetProperty("wal_checkpoint_attempted").GetBoolean()); + Assert.False(document.RootElement.GetProperty("wal_checkpoint_succeeded").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunMap_HumanLargestFilesFormatsSizesAndBytesFlagKeepsRawCounts() { diff --git a/tests/CodeIndex.Tests/golden/status.json b/tests/CodeIndex.Tests/golden/status.json index b896921157..dba19be6e2 100644 --- a/tests/CodeIndex.Tests/golden/status.json +++ b/tests/CodeIndex.Tests/golden/status.json @@ -7,6 +7,10 @@ "indexed_at": "\u003CTIMESTAMP\u003E", "latest_modified": "\u003CTIMESTAMP\u003E", "project_root": "\u003CPROJECT_ROOT\u003E", + "db_file_mode": "0600", + "read_only_fallback": false, + "wal_checkpoint_attempted": false, + "wal_checkpoint_succeeded": false, "git_head": null, "git_is_dirty": null, "languages": { From 39c0cdce8afba69cf0df89a46dc9a10fb22b5877 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 12:44:24 +0900 Subject: [PATCH 2/3] Restore read-only index error path (#1798) --- src/CodeIndex/Cli/IndexCommandRunner.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 49789d0640..8cdb304e53 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -183,7 +183,7 @@ internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, C return WriteCommandError( options.Json, jsonOptions, - "database opened through stale read-only fallback after WAL checkpoint failed; index requires a writable database", + $"database opened through stale read-only fallback after WAL checkpoint failed: {resolvedDbPath}; index requires a writable database", CommandExitCodes.DatabaseError, "Move the database to writable storage, stop the writer holding the WAL lock, or rerun the query command with --read-only if you only need read access.", CommandErrorCodes.DbNotWritable); From a26f03fd2c4073dfb1c4918c459fc09c0985b2ad Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:03:06 +0900 Subject: [PATCH 3/3] Stabilize status snapshot across OSes (#1798) --- tests/CodeIndex.Tests/JsonOutputSnapshotHelper.cs | 1 + tests/CodeIndex.Tests/golden/status.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CodeIndex.Tests/JsonOutputSnapshotHelper.cs b/tests/CodeIndex.Tests/JsonOutputSnapshotHelper.cs index 06a070b2e5..32bff6abd1 100644 --- a/tests/CodeIndex.Tests/JsonOutputSnapshotHelper.cs +++ b/tests/CodeIndex.Tests/JsonOutputSnapshotHelper.cs @@ -57,6 +57,7 @@ internal static class JsonOutputSnapshotHelper private static readonly HashSet PlatformOptionalKeys = new(StringComparer.Ordinal) { "data_dir_mode", + "db_file_mode", }; private static readonly HashSet VolatileCountKeys = new(StringComparer.Ordinal) diff --git a/tests/CodeIndex.Tests/golden/status.json b/tests/CodeIndex.Tests/golden/status.json index dba19be6e2..a9cd45a019 100644 --- a/tests/CodeIndex.Tests/golden/status.json +++ b/tests/CodeIndex.Tests/golden/status.json @@ -7,7 +7,6 @@ "indexed_at": "\u003CTIMESTAMP\u003E", "latest_modified": "\u003CTIMESTAMP\u003E", "project_root": "\u003CPROJECT_ROOT\u003E", - "db_file_mode": "0600", "read_only_fallback": false, "wal_checkpoint_attempted": false, "wal_checkpoint_succeeded": false,