diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7ef4a341c2..e340d80ca7 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -121,9 +121,14 @@ long backfills. ## 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. +`0600` to `codeindex.db` plus WAL/SHM sidecars when they exist. Index lock +metadata sidecars and the active workspace `active.json` state file are also +written as owner-only files and read through small bounded buffers so stale or +corrupted diagnostics cannot expose local paths more broadly or force unbounded +allocation. Database checkpoint roots, snapshot directories, manifest files, +copied DB/WAL/SHM snapshots, and restore staging/backup directories are also +forced owner-only on POSIX. `status --json` reports `data_dir_mode` and +`db_file_mode` when the platform exposes Unix file modes. ## Release Distribution Checklist diff --git a/changelog.d/unreleased/2868.security.md b/changelog.d/unreleased/2868.security.md new file mode 100644 index 0000000000..ac03422f22 --- /dev/null +++ b/changelog.d/unreleased/2868.security.md @@ -0,0 +1,21 @@ +--- +category: security +issues: + - 2868 +affected: + - src/CodeIndex/Cli/DataDirectorySecurity.cs + - src/CodeIndex/Cli/IndexLock.cs + - src/CodeIndex/Mcp/McpIndexRunLock.cs + - tests/CodeIndex.Tests/DataDirectorySecurityTests.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Index lock metadata is now private and bounded (#2868)** — CLI and MCP index lock `.info` sidecars are written with owner-only permissions on POSIX and ignored when they exceed a small diagnostic read limit. + +## 日本語 + +- **index lock metadata を private かつ bounded にしました (#2868)** — CLI と MCP の index lock `.info` sidecar は POSIX で所有者のみの権限で書き込まれ、小さな診断用 read 上限を超える場合は無視されます。 diff --git a/changelog.d/unreleased/2869.security.md b/changelog.d/unreleased/2869.security.md new file mode 100644 index 0000000000..497771c325 --- /dev/null +++ b/changelog.d/unreleased/2869.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2869 +affected: + - src/CodeIndex/Cli/ActiveWorkspace.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Active workspace state is now private and bounded (#2869)** — `active.json` is written with owner-only permissions on POSIX, oversized state files are ignored before JSON parsing, and invalid state files now emit a non-fatal diagnostic. + +## 日本語 + +- **active workspace state を private かつ bounded にしました (#2869)** — `active.json` は POSIX で所有者のみの権限で書き込まれ、過大な state file は JSON parsing 前に無視され、無効な state file では非 fatal の診断を出力します。 diff --git a/changelog.d/unreleased/2879.security.md b/changelog.d/unreleased/2879.security.md new file mode 100644 index 0000000000..0afd236df2 --- /dev/null +++ b/changelog.d/unreleased/2879.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2879 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Database checkpoints now use private snapshot permissions (#2879)** — checkpoint roots, snapshot directories, manifest files, and copied DB/WAL/SHM files are forced owner-only on POSIX filesystems. + +## 日本語 + +- **database checkpoint の snapshot permission を private にしました (#2879)** — checkpoint root、snapshot directory、manifest file、コピーされた DB/WAL/SHM file は POSIX filesystem で所有者のみの権限に強制されます。 diff --git a/changelog.d/unreleased/2882.security.md b/changelog.d/unreleased/2882.security.md new file mode 100644 index 0000000000..49cd183ba7 --- /dev/null +++ b/changelog.d/unreleased/2882.security.md @@ -0,0 +1,17 @@ +--- +category: security +issues: + - 2882 +affected: + - src/CodeIndex/Cli/DbCommandRunner.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Database restore staging and backup paths now use private permissions (#2882)** — restore temp directories, backup directories, and moved or staged DB/WAL/SHM files are forced owner-only on POSIX filesystems. + +## 日本語 + +- **database restore の staging / backup path を private permission にしました (#2882)** — restore temp directory、backup directory、移動または staging された DB/WAL/SHM file は POSIX filesystem で所有者のみの権限に強制されます。 diff --git a/src/CodeIndex/Cli/ActiveWorkspace.cs b/src/CodeIndex/Cli/ActiveWorkspace.cs index 88f4674d31..a57ecc9fb6 100644 --- a/src/CodeIndex/Cli/ActiveWorkspace.cs +++ b/src/CodeIndex/Cli/ActiveWorkspace.cs @@ -8,6 +8,7 @@ internal sealed record ActiveWorkspaceState(string Name, string Root, string DbP internal static class ActiveWorkspace { internal const string EnvironmentVariable = "CDIDX_ACTIVE_WORKSPACE"; + private const int MaxStateBytes = 64 * 1024; internal static string StatePath { @@ -33,7 +34,14 @@ internal static string StatePath try { - using var document = JsonDocument.Parse(File.ReadAllText(LongPath.EnsureWindowsPrefix(path))); + var text = DataDirectorySecurity.ReadTextWithinLimit(path, MaxStateBytes, FileShare.ReadWrite); + if (text is null) + { + WriteLoadWarning(path, $"file exceeds {MaxStateBytes} bytes"); + return null; + } + + using var document = JsonDocument.Parse(text); var root = document.RootElement; var name = ReadString(root, "name") ?? "default"; var workspaceRoot = ReadString(root, "root") ?? Environment.CurrentDirectory; @@ -44,17 +52,21 @@ internal static string StatePath } catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { + WriteLoadWarning(path, ex.Message); return null; } } internal static void Save(ActiveWorkspaceState state) { - Directory.CreateDirectory(Path.GetDirectoryName(StatePath)!); + DataDirectorySecurity.CreateSensitiveDirectory(Path.GetDirectoryName(StatePath)!); var payload = new ActiveWorkspaceState(state.Name, Path.GetFullPath(state.Root), Path.GetFullPath(state.DbPath)); - File.WriteAllText(StatePath, JsonSerializer.Serialize(payload, ProgramRunner.CreateDefaultJsonOptions())); + DataDirectorySecurity.WritePrivateText(StatePath, JsonSerializer.Serialize(payload, ProgramRunner.CreateDefaultJsonOptions())); } private static string? ReadString(JsonElement element, string name) => element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null; + + private static void WriteLoadWarning(string path, string reason) + => Console.Error.WriteLine($"[cdidx] Ignoring active workspace state at {path}: {reason}"); } diff --git a/src/CodeIndex/Cli/DataDirectorySecurity.cs b/src/CodeIndex/Cli/DataDirectorySecurity.cs index ccd4b29e1e..e57fe808b9 100644 --- a/src/CodeIndex/Cli/DataDirectorySecurity.cs +++ b/src/CodeIndex/Cli/DataDirectorySecurity.cs @@ -1,13 +1,20 @@ using System.Runtime.InteropServices; +using System.Text; +using CodeIndex.Indexer; namespace CodeIndex.Cli; internal static class DataDirectorySecurity { + private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + internal const UnixFileMode PrivateDirectoryMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute; - private const UnixFileMode PermissionBits = + internal const UnixFileMode PrivateFileMode = + UnixFileMode.UserRead | UnixFileMode.UserWrite; + + internal const UnixFileMode PermissionBits = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute; @@ -20,6 +27,13 @@ public static DirectoryInfo CreatePrivateDirectory(string path) return directory; } + public static DirectoryInfo CreateSensitiveDirectory(string path) + { + var directory = Directory.CreateDirectory(path); + ApplyPrivateMode(directory.FullName); + return directory; + } + public static void ApplyPrivateMode(string path) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -28,6 +42,52 @@ public static void ApplyPrivateMode(string path) File.SetUnixFileMode(path, PrivateDirectoryMode); } + public static void ApplyPrivateFileMode(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + File.SetUnixFileMode(path, PrivateFileMode); + } + + public static void WritePrivateText(string path, string contents, Encoding? encoding = null) + { + var ioPath = LongPath.EnsureWindowsPrefix(path); + using var stream = File.Open(ioPath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read); + ApplyPrivateFileMode(ioPath); + stream.SetLength(0); + var outputEncoding = encoding is null || encoding.CodePage == Encoding.UTF8.CodePage ? Utf8NoBom : encoding; + using var writer = new StreamWriter(stream, outputEncoding); + writer.Write(contents); + } + + public static string? ReadTextWithinLimit(string path, int maxBytes, FileShare share = FileShare.Read) + { + if (maxBytes <= 0) + throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "Maximum byte count must be positive."); + + var ioPath = LongPath.EnsureWindowsPrefix(path); + using var stream = File.Open(ioPath, FileMode.Open, FileAccess.Read, share); + using var output = new MemoryStream(capacity: Math.Min(maxBytes, 8192)); + var buffer = new byte[Math.Min(maxBytes + 1, 8192)]; + var total = 0; + while (true) + { + var remaining = maxBytes + 1 - total; + var read = stream.Read(buffer, 0, Math.Min(buffer.Length, remaining)); + if (read == 0) + break; + + total += read; + if (total > maxBytes) + return null; + + output.Write(buffer, 0, read); + } + + return Encoding.UTF8.GetString(output.ToArray()); + } + public static string? GetUnixModeString(string? path) { if (string.IsNullOrWhiteSpace(path) || diff --git a/src/CodeIndex/Cli/DbCommandRunner.cs b/src/CodeIndex/Cli/DbCommandRunner.cs index a2bc44334b..ce03fefbc5 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.cs @@ -581,15 +581,15 @@ private static DbCheckpointOperationResult CreateCheckpoint(string fullDbPath, s if (Directory.Exists(checkpointPath)) throw new InvalidOperationException($"checkpoint already exists: {name}"); - Directory.CreateDirectory(root); + DataDirectorySecurity.CreateSensitiveDirectory(root); var tempPath = Path.Combine(root, ".tmp-" + name + "-" + Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempPath); + DataDirectorySecurity.CreateSensitiveDirectory(tempPath); try { - CopyIfExists(fullDbPath, Path.Combine(tempPath, Path.GetFileName(fullDbPath))); - CopyIfExists(fullDbPath + "-wal", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-wal")); - CopyIfExists(fullDbPath + "-shm", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-shm")); - File.WriteAllText(Path.Combine(tempPath, "manifest.txt"), $"name={name}{Environment.NewLine}created_at_utc={DateTimeOffset.UtcNow:O}{Environment.NewLine}db={fullDbPath}{Environment.NewLine}"); + CopyIfExists(fullDbPath, Path.Combine(tempPath, Path.GetFileName(fullDbPath)), privateDestination: true); + CopyIfExists(fullDbPath + "-wal", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); + CopyIfExists(fullDbPath + "-shm", Path.Combine(tempPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); + DataDirectorySecurity.WritePrivateText(Path.Combine(tempPath, "manifest.txt"), $"name={name}{Environment.NewLine}created_at_utc={DateTimeOffset.UtcNow:O}{Environment.NewLine}db={fullDbPath}{Environment.NewLine}"); Directory.Move(tempPath, checkpointPath); } catch @@ -633,25 +633,25 @@ private static string RestoreCheckpoint(string fullDbPath, string name, string c var restoreTempPath = fullDbPath + ".restore-tmp-" + DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture); var backupPath = fullDbPath + ".restore-backup-" + DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", System.Globalization.CultureInfo.InvariantCulture); - Directory.CreateDirectory(restoreTempPath); + DataDirectorySecurity.CreateSensitiveDirectory(restoreTempPath); try { - CopyIfExists(checkpointDbPath, Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath))); - CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-wal"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal")); - CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-shm"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm")); + CopyIfExists(checkpointDbPath, Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), privateDestination: true); + CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-wal"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); + CopyIfExists(Path.Combine(checkpointPath, Path.GetFileName(fullDbPath) + "-shm"), Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); if (!File.Exists(LongPath.EnsureWindowsPrefix(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath))))) throw new InvalidOperationException($"checkpoint staging failed: {name}"); - Directory.CreateDirectory(backupPath); - MoveIfExists(fullDbPath, Path.Combine(backupPath, Path.GetFileName(fullDbPath))); - MoveIfExists(fullDbPath + "-wal", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal")); - MoveIfExists(fullDbPath + "-shm", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm")); + DataDirectorySecurity.CreateSensitiveDirectory(backupPath); + MoveIfExists(fullDbPath, Path.Combine(backupPath, Path.GetFileName(fullDbPath)), privateDestination: true); + MoveIfExists(fullDbPath + "-wal", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), privateDestination: true); + MoveIfExists(fullDbPath + "-shm", Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), privateDestination: true); RestoreFailureAfterBackupForTesting?.Invoke(); - MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), fullDbPath); - MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal"); - MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm"); + MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath)), fullDbPath, privateDestination: true); + MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal", privateDestination: true); + MoveIfExists(Path.Combine(restoreTempPath, Path.GetFileName(fullDbPath) + "-shm"), fullDbPath + "-shm", privateDestination: true); } catch { @@ -689,16 +689,24 @@ private static string GetCheckpointPath(string fullDbPath, string name) return Path.Combine(GetCheckpointRoot(fullDbPath), name); } - private static void CopyIfExists(string source, string destination) + private static void CopyIfExists(string source, string destination, bool privateDestination = false) { if (File.Exists(LongPath.EnsureWindowsPrefix(source))) + { File.Copy(LongPath.EnsureWindowsPrefix(source), LongPath.EnsureWindowsPrefix(destination), overwrite: false); + if (privateDestination) + DataDirectorySecurity.ApplyPrivateFileMode(destination); + } } - private static void MoveIfExists(string source, string destination) + private static void MoveIfExists(string source, string destination, bool privateDestination = false) { if (File.Exists(LongPath.EnsureWindowsPrefix(source))) + { File.Move(LongPath.EnsureWindowsPrefix(source), LongPath.EnsureWindowsPrefix(destination)); + if (privateDestination) + DataDirectorySecurity.ApplyPrivateFileMode(destination); + } } private static void RestoreBackedUpFiles(string fullDbPath, string backupPath) @@ -709,9 +717,9 @@ private static void RestoreBackedUpFiles(string fullDbPath, string backupPath) DeleteIfExists(fullDbPath); DeleteIfExists(fullDbPath + "-wal"); DeleteIfExists(fullDbPath + "-shm"); - MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath)), fullDbPath); - MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-wal"), fullDbPath + "-wal"); - MoveIfExists(Path.Combine(backupPath, Path.GetFileName(fullDbPath) + "-shm"), 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) diff --git a/src/CodeIndex/Cli/IndexLock.cs b/src/CodeIndex/Cli/IndexLock.cs index ae1aa43659..8ae9083b64 100644 --- a/src/CodeIndex/Cli/IndexLock.cs +++ b/src/CodeIndex/Cli/IndexLock.cs @@ -23,6 +23,8 @@ namespace CodeIndex.Cli; /// internal sealed class IndexLock : IDisposable { + private const int MaxInfoBytes = 16 * 1024; + private readonly FileStream _stream; private readonly string _lockPath; private readonly string _infoPath; @@ -100,7 +102,7 @@ public static IndexLock Acquire(string lockPath, string projectPath) StartedAt: DateTime.UtcNow, Host: Environment.MachineName, ProjectPath: Path.GetFullPath(projectPath)); - File.WriteAllText(infoPath, SerializeInfo(info), Encoding.UTF8); + DataDirectorySecurity.WritePrivateText(infoPath, SerializeInfo(info), Encoding.UTF8); } catch (Exception) { @@ -129,13 +131,7 @@ public static IndexLock Acquire(string lockPath, string projectPath) // diagnostic read is never rejected for share-mode mismatch. // 保持者が .info を上書きしている可能性があるため FileShare.ReadWrite で // 開き、共有モード不一致で読みを失敗させない。 - using var stream = new FileStream( - ioInfoPath, - FileMode.Open, - FileAccess.Read, - FileShare.ReadWrite); - using var reader = new StreamReader(stream, Encoding.UTF8); - var text = reader.ReadToEnd(); + var text = DataDirectorySecurity.ReadTextWithinLimit(ioInfoPath, MaxInfoBytes, FileShare.ReadWrite); if (string.IsNullOrWhiteSpace(text)) return null; return ParseInfo(text); diff --git a/src/CodeIndex/Mcp/McpIndexRunLock.cs b/src/CodeIndex/Mcp/McpIndexRunLock.cs index 28c2563965..528ab3b610 100644 --- a/src/CodeIndex/Mcp/McpIndexRunLock.cs +++ b/src/CodeIndex/Mcp/McpIndexRunLock.cs @@ -1,11 +1,13 @@ using System.Diagnostics; using System.Text.Json; +using CodeIndex.Cli; namespace CodeIndex.Mcp; internal sealed class McpIndexRunLock : IDisposable { internal const string LockFileName = "index.lock"; + private const int MaxInfoBytes = 4 * 1024; private static readonly TimeSpan StaleInfoGracePeriod = TimeSpan.FromSeconds(2); private readonly FileStream _stream; @@ -65,7 +67,7 @@ private void WriteHolderInfo() var since = DateTimeOffset.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture); try { - File.WriteAllText(_infoPath, $$"""{"pid":{{Environment.ProcessId}},"since":"{{since}}"}"""); + DataDirectorySecurity.WritePrivateText(_infoPath, $$"""{"pid":{{Environment.ProcessId}},"since":"{{since}}"}"""); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -91,7 +93,11 @@ private static string BuildBusyMessage(string infoPath) if (!File.Exists(infoPath)) return null; - using var document = JsonDocument.Parse(File.ReadAllText(infoPath)); + var text = DataDirectorySecurity.ReadTextWithinLimit(infoPath, MaxInfoBytes, FileShare.ReadWrite); + if (string.IsNullOrWhiteSpace(text)) + return null; + + using var document = JsonDocument.Parse(text); var root = document.RootElement; if (!root.TryGetProperty("pid", out var pidElement) || !pidElement.TryGetInt32(out var pid)) return null; @@ -107,7 +113,7 @@ private static string BuildBusyMessage(string infoPath) return new HolderInfo(pid, since.ToUniversalTime(), IsProcessStillRunning(pid)); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException or ArgumentException or NotSupportedException) { return null; } diff --git a/tests/CodeIndex.Tests/DataDirectorySecurityTests.cs b/tests/CodeIndex.Tests/DataDirectorySecurityTests.cs index 4c61d04ac5..0c9de4cb47 100644 --- a/tests/CodeIndex.Tests/DataDirectorySecurityTests.cs +++ b/tests/CodeIndex.Tests/DataDirectorySecurityTests.cs @@ -20,7 +20,7 @@ public void CreatePrivateDirectory_OnPosix_Forces0700Mode() Assert.Equal("0700", DataDirectorySecurity.GetUnixModeString(cdidxDir)); Assert.Equal( DataDirectorySecurity.PrivateDirectoryMode, - File.GetUnixFileMode(cdidxDir) & DataDirectorySecurity.PrivateDirectoryMode); + File.GetUnixFileMode(cdidxDir) & DataDirectorySecurity.PermissionBits); } finally { @@ -36,4 +36,72 @@ public void GetUnixModeString_OnMissingDirectory_ReturnsNull() Assert.Null(DataDirectorySecurity.GetUnixModeString(missing)); } + + [Fact] + public void CreateSensitiveDirectory_OnPosix_Forces0700Mode() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + var root = Path.Combine(Path.GetTempPath(), $"cdidx_sensitive_dir_security_{Guid.NewGuid():N}"); + var sensitiveDir = Path.Combine(root, "state"); + try + { + DataDirectorySecurity.CreateSensitiveDirectory(sensitiveDir); + + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(sensitiveDir) & DataDirectorySecurity.PermissionBits); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void WritePrivateText_OnPosix_Forces0600Mode() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + var root = Path.Combine(Path.GetTempPath(), $"cdidx_sensitive_file_security_{Guid.NewGuid():N}"); + var path = Path.Combine(root, "metadata.info"); + try + { + Directory.CreateDirectory(root); + + DataDirectorySecurity.WritePrivateText(path, "secret"); + + Assert.Equal("secret", File.ReadAllText(path)); + Assert.Equal( + DataDirectorySecurity.PrivateFileMode, + File.GetUnixFileMode(path) & DataDirectorySecurity.PermissionBits); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void ReadTextWithinLimit_WhenFileExceedsLimit_ReturnsNull() + { + var root = Path.Combine(Path.GetTempPath(), $"cdidx_bounded_read_{Guid.NewGuid():N}"); + var path = Path.Combine(root, "metadata.info"); + try + { + Directory.CreateDirectory(root); + File.WriteAllText(path, new string('x', 17)); + + Assert.Null(DataDirectorySecurity.ReadTextWithinLimit(path, maxBytes: 16)); + } + finally + { + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } } diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 9428e6352a..7674b61928 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -281,6 +281,41 @@ public void Run_CheckpointAndRestore_RestoresDatabaseBytes() } } + [Fact] + public void Run_Checkpoint_OnPosix_WritesPrivateSnapshotPermissions() + { + if (OperatingSystem.IsWindows()) + return; + + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_checkpoint_private_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(root, "codeindex.db"); + try + { + Directory.CreateDirectory(root); + File.WriteAllText(dbPath, "db"); + File.WriteAllText(dbPath + "-wal", "wal"); + File.WriteAllText(dbPath + "-shm", "shm"); + + var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "private", "--db", dbPath]); + + Assert.Equal(CommandExitCodes.Success, checkpointExit); + var checkpointRoot = dbPath + ".checkpoints"; + var checkpointPath = Path.Combine(checkpointRoot, "private"); + AssertPrivateDirectory(checkpointRoot); + AssertPrivateDirectory(checkpointPath); + AssertPrivateFile(Path.Combine(checkpointPath, "codeindex.db")); + AssertPrivateFile(Path.Combine(checkpointPath, "codeindex.db-wal")); + AssertPrivateFile(Path.Combine(checkpointPath, "codeindex.db-shm")); + AssertPrivateFile(Path.Combine(checkpointPath, "manifest.txt")); + } + finally + { + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_CheckpointsList_JsonIncludesCreatedCheckpoint() { @@ -379,6 +414,52 @@ public void Run_RestoreFailureAfterBackup_RestoresOriginalDatabase() } } + [Fact] + public void Run_Restore_OnPosix_CreatesPrivateStagingAndBackupPermissions() + { + if (OperatingSystem.IsWindows()) + return; + + var root = Path.Combine(Path.GetTempPath(), $"cdidx_db_restore_private_{Guid.NewGuid():N}"); + var dbPath = Path.Combine(root, "codeindex.db"); + var inspected = false; + try + { + Directory.CreateDirectory(root); + File.WriteAllText(dbPath, "original"); + var (checkpointExit, _, _) = RunAndCaptureStreams(["checkpoint", "saved", "--db", dbPath]); + Assert.Equal(CommandExitCodes.Success, checkpointExit); + + File.WriteAllText(dbPath, "changed"); + DbCommandRunner.RestoreFailureAfterBackupForTesting = () => + { + var restoreTempPath = Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-tmp-*")); + var backupPath = Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-backup-*")); + AssertPrivateDirectory(restoreTempPath); + AssertPrivateDirectory(backupPath); + AssertPrivateFile(Path.Combine(restoreTempPath, "codeindex.db")); + AssertPrivateFile(Path.Combine(backupPath, "codeindex.db")); + inspected = true; + }; + + var (restoreExit, _, _) = RunAndCaptureStreams(["restore", "saved", "--db", dbPath]); + + Assert.Equal(CommandExitCodes.Success, restoreExit); + Assert.True(inspected); + AssertPrivateFile(dbPath); + var finalBackupPath = Assert.Single(Directory.GetDirectories(root, "codeindex.db.restore-backup-*")); + AssertPrivateDirectory(finalBackupPath); + AssertPrivateFile(Path.Combine(finalBackupPath, "codeindex.db")); + } + finally + { + DbCommandRunner.RestoreFailureAfterBackupForTesting = null; + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) + Directory.Delete(root, recursive: true); + } + } + [Fact] public void Run_CorruptedDb_ReturnsDatabaseError() { @@ -426,6 +507,24 @@ public void Run_CorruptedDb_ReturnsDatabaseError() return (exitCode, document.RootElement.Clone()); } + private static void AssertPrivateDirectory(string path) + { +#pragma warning disable CA1416 + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(path) & DataDirectorySecurity.PermissionBits); +#pragma warning restore CA1416 + } + + private static void AssertPrivateFile(string path) + { +#pragma warning disable CA1416 + Assert.Equal( + DataDirectorySecurity.PrivateFileMode, + File.GetUnixFileMode(path) & DataDirectorySecurity.PermissionBits); +#pragma warning restore CA1416 + } + private static void SeedOrphans(string dbPath) { using var connection = new SqliteConnection(new SqliteConnectionStringBuilder diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index a0ac75c620..2b3abe3adf 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -9224,6 +9224,61 @@ private static void ClearAttributes(string path) File.SetAttributes(path, FileAttributes.Normal); } + [Fact] + public void IndexLock_Acquire_OnPosix_WritesPrivateInfoFile() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_private_lock_{Guid.NewGuid():N}.db"); + var lockPath = dbPath + ".lock"; + var infoPath = lockPath + ".info"; + try + { + using var indexLock = IndexLock.Acquire(lockPath, projectRoot); + + Assert.True(File.Exists(infoPath)); + Assert.Equal( + DataDirectorySecurity.PrivateFileMode, + File.GetUnixFileMode(infoPath) & DataDirectorySecurity.PermissionBits); + } + finally + { + DeleteDirectory(projectRoot); + if (File.Exists(infoPath)) + File.Delete(infoPath); + if (File.Exists(lockPath)) + File.Delete(lockPath); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + + [Fact] + public void IndexLock_TryReadHolderInfo_WhenInfoFileTooLarge_ReturnsNull() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_large_lock_{Guid.NewGuid():N}.db"); + var lockPath = dbPath + ".lock"; + var infoPath = lockPath + ".info"; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + File.WriteAllText(infoPath, new string('x', 17 * 1024)); + + Assert.Null(IndexLock.TryReadHolderInfo(lockPath)); + } + finally + { + if (File.Exists(infoPath)) + File.Delete(infoPath); + if (File.Exists(lockPath)) + File.Delete(lockPath); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void Run_LockHeldByAnotherHolder_RejectedWithHolderInfo() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 2582fff480..def87e3e73 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6906,6 +6906,87 @@ public void ToolsCall_Index_WhenDbLockHeld_ReturnsBusyError() } } + [Fact] + public void McpIndexRunLock_TryAcquire_OnPosix_WritesPrivateInfoFile() + { + if (OperatingSystem.IsWindows()) + return; + + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_private_lock_{Guid.NewGuid():N}.db"); + var lockPath = McpIndexRunLock.ResolveLockPath(dbPath); + var infoPath = lockPath + ".info"; + try + { + Assert.True(McpIndexRunLock.TryAcquire(dbPath, out var runLock, out var error), error); + Assert.NotNull(runLock); + using (runLock!) + { + Assert.True(File.Exists(infoPath)); + Assert.Equal( + DataDirectorySecurity.PrivateFileMode, + File.GetUnixFileMode(infoPath) & DataDirectorySecurity.PermissionBits); + } + } + finally + { + if (File.Exists(infoPath)) + File.Delete(infoPath); + if (File.Exists(lockPath)) + File.Delete(lockPath); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + + [Fact] + public void ToolsCall_Index_WhenDbLockInfoTooLarge_ReturnsBusyWithoutHolderDetails() + { + var fixtureDir = Path.Combine(Path.GetFullPath("."), $"mcp_index_large_lock_fixture_{Guid.NewGuid():N}"); + Directory.CreateDirectory(fixtureDir); + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_mcp_index_large_lock_{Guid.NewGuid():N}.db"); + var lockPath = McpIndexRunLock.ResolveLockPath(dbPath); + Directory.CreateDirectory(Path.GetDirectoryName(lockPath)!); + var infoPath = lockPath + ".info"; + File.WriteAllText(infoPath, $$"""{"pid":{{Environment.ProcessId}},"since":"2026-01-02T03:04:05.0000000+00:00","padding":"{{new string('x', 5 * 1024)}}"}"""); + using var heldLock = new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + using var server = new McpServer(dbPath, ConsoleUi.LoadVersion(), dbPathExplicit: true); + try + { + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = 1, + ["method"] = "tools/call", + ["params"] = new JsonObject + { + ["name"] = "index", + ["arguments"] = new JsonObject + { + ["path"] = fixtureDir + } + } + }; + + var response = server.HandleMessage(request)!; + + Assert.True(response["result"]!["isError"]!.GetValue()); + var text = response["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("index already running on this DB", text); + Assert.Contains("holder metadata unavailable", text); + Assert.DoesNotContain($"pid {Environment.ProcessId}", text); + } + finally + { + heldLock.Dispose(); + File.Delete(infoPath); + File.Delete(lockPath); + if (Directory.Exists(fixtureDir)) + Directory.Delete(fixtureDir, recursive: true); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void ToolsCall_Index_NonexistentDir_ReturnsError() { diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 9f77f36276..1a0014f3af 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -154,9 +154,78 @@ public void MalformedActiveWorkspaceState_DoesNotOverrideQueryResolution() Directory.CreateDirectory(Path.GetDirectoryName(ActiveWorkspace.StatePath)!); File.WriteAllText(ActiveWorkspace.StatePath, "{"); - var query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + DbPathResolution? query = null; + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + return 0; + }); + + Assert.NotNull(query); + Assert.Contains("Ignoring active workspace state", stderr); + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query!.DbPath); + Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(configHome); + } + } + + [Fact] + public void ActiveWorkspaceSave_OnPosix_WritesPrivateStateFile() + { + if (OperatingSystem.IsWindows()) + return; + + var configHome = TestProjectHelper.CreateTempProject("cdidx_active_workspace_private_config"); + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_private_project"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + + ActiveWorkspace.Save(new ActiveWorkspaceState("default", projectRoot, Path.Combine(projectRoot, ".cdidx", "codeindex.db"))); + + Assert.Equal( + DataDirectorySecurity.PrivateDirectoryMode, + File.GetUnixFileMode(Path.GetDirectoryName(ActiveWorkspace.StatePath)!) & DataDirectorySecurity.PermissionBits); + Assert.Equal( + DataDirectorySecurity.PrivateFileMode, + File.GetUnixFileMode(ActiveWorkspace.StatePath) & DataDirectorySecurity.PermissionBits); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + TestProjectHelper.DeleteDirectory(configHome); + } + } + + [Fact] + public void OversizedActiveWorkspaceState_DoesNotOverrideQueryResolution() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_active_workspace_large_project"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_active_workspace_large_config"); + try + { + using var env = EnvironmentVariableScope.Capture(ActiveWorkspace.EnvironmentVariable, "XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable(ActiveWorkspace.EnvironmentVariable, null); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + Directory.CreateDirectory(Path.GetDirectoryName(ActiveWorkspace.StatePath)!); + File.WriteAllText(ActiveWorkspace.StatePath, new string('x', 65 * 1024)); + + DbPathResolution? query = null; + var (_, _, stderr) = ConsoleCapture.Capture(() => + { + query = DbPathResolver.ResolveForQuery(projectRoot, explicitDbPath: null, explicitDataDir: null); + return 0; + }); - Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query.DbPath); + Assert.NotNull(query); + Assert.Contains("file exceeds", stderr); + Assert.Equal(Path.Combine(projectRoot, ".cdidx", "codeindex.db"), query!.DbPath); Assert.Equal(DbPathResolver.DataDirSourceWorkspace, query.DataDirSource); } finally