Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ consumer can run `cdidx import codeindex.cdidx.zip --db <path>` before query
commands. Use `--prune-paths` on import when the archive comes from another
checkout and the restored DB should advertise the current workspace root. The
archive contains `manifest.json` plus `codeindex.db`; import validates the
embedded SQLite file as a CodeIndex database before replacing the destination
DB.
manifest format, manifest `user_version`, `database_sha256`, and embedded
SQLite file as a CodeIndex database before replacing the destination DB.
Import rejects archive `codeindex.db` entries whose compressed or uncompressed
metadata exceeds 8 GiB, and the extraction stream is also capped at 8 GiB.

Use `cdidx db checkpoint <name>` to take a filesystem snapshot of
`codeindex.db` plus existing WAL/SHM sidecars before risky maintenance, and use
Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2834.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2834
affected:
- DEVELOPER_GUIDE.md
- src/CodeIndex/Cli/ExportImportCommandRunner.cs
- tests/CodeIndex.Tests/ProgramCliTests.cs
---

## English

- **`cdidx import` now verifies archive manifest hashes (#2834)** — import reads `manifest.json`, rejects unsupported manifest/schema versions, and compares `database_sha256` against the embedded `codeindex.db` before installing the database.

## 日本語

- **`cdidx import` がarchive manifestのhashを検証するようになりました (#2834)** — import は `manifest.json` を読み取り、未対応のmanifest/schema versionを拒否し、databaseを配置する前に `database_sha256` と内包された `codeindex.db` を照合します。
17 changes: 17 additions & 0 deletions changelog.d/unreleased/2835.security.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
category: security
issues:
- 2835
affected:
- DEVELOPER_GUIDE.md
- src/CodeIndex/Cli/ExportImportCommandRunner.cs
- tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs
---

## English

- **`cdidx import` now bounds archive database extraction (#2835)** — import rejects oversized `codeindex.db` entries from archive metadata and stops stream extraction if the database grows past the fixed import limit.

## 日本語

- **`cdidx import` がarchive databaseの展開サイズを制限するようになりました (#2835)** — import はarchive metadata上の過大な `codeindex.db` entryを拒否し、databaseが固定のimport上限を超えた場合はstream展開中にも停止します。
150 changes: 148 additions & 2 deletions src/CodeIndex/Cli/ExportImportCommandRunner.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.IO.Compression;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
Expand All @@ -11,6 +12,8 @@ internal static class ExportImportCommandRunner
{
private const string ManifestEntryName = "manifest.json";
private const string DatabaseEntryName = "codeindex.db";
internal const long MaxImportDatabaseBytes = 8L * 1024 * 1024 * 1024;
private const int ImportCopyBufferSize = 81920;
private static readonly DateTimeOffset DeterministicZipTimestamp = new(1980, 1, 1, 0, 0, 0, TimeSpan.Zero);

public static int RunExport(string[] args, JsonSerializerOptions jsonOptions, string appVersion)
Expand Down Expand Up @@ -73,14 +76,24 @@ public static int RunImport(string[] args, JsonSerializerOptions jsonOptions)
Directory.CreateDirectory(dbDirectory);
using (var archive = ZipFile.OpenRead(archivePath))
{
if (archive.GetEntry(ManifestEntryName) == null)
var manifestEntry = archive.GetEntry(ManifestEntryName);
if (manifestEntry == null)
return WriteError("archive is missing manifest.json.", "use an archive produced by `cdidx export <archive>`.", "cdidx import <archive> [--db <path>] [--json]");
if (!TryReadManifest(manifestEntry, jsonOptions, out var manifest, out var manifestError))
return WriteError($"archive manifest is invalid: {manifestError}.", "use an archive produced by `cdidx export <archive>`.", "cdidx import <archive> [--db <path>] [--json]");
if (!TryValidateManifestHeader(manifest, out var manifestHeaderError))
return WriteError($"archive manifest is invalid: {manifestHeaderError}.", "re-export from a compatible CodeIndex database.", "cdidx import <archive> [--db <path>] [--json]");

var dbEntry = archive.GetEntry(DatabaseEntryName);
if (dbEntry == null)
return WriteError("archive is missing codeindex.db.", "use an archive produced by `cdidx export <archive>`.", "cdidx import <archive> [--db <path>] [--json]");
if (!TryValidateDatabaseEntrySize(dbEntry.Length, dbEntry.CompressedLength, out var sizeValidationMessage))
return WriteError(sizeValidationMessage, "re-export a smaller CodeIndex database or rebuild a smaller index.", "cdidx import <archive> [--db <path>] [--prune-paths] [--json]");

dbEntry.ExtractToFile(tempPath, overwrite: true);
ExtractDatabaseEntryToFile(dbEntry, tempPath);

if (!TryValidateImportedManifest(manifest, tempPath, out var manifestValidationMessage))
return WriteError($"archive manifest mismatch: {manifestValidationMessage}.", "re-export from a compatible CodeIndex database.", "cdidx import <archive> [--db <path>] [--prune-paths] [--json]");
}

if (!DbContext.TryValidateExistingCodeIndexDb(tempPath, out var validationMessage, out _))
Expand Down Expand Up @@ -306,6 +319,139 @@ private static string ComputeSha256(string path)
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
}

private static bool TryReadManifest(ZipArchiveEntry manifestEntry, JsonSerializerOptions jsonOptions, out ExportManifest manifest, out string message)
{
try
{
using var stream = manifestEntry.Open();
manifest = JsonSerializer.Deserialize<ExportManifest>(stream, jsonOptions)
?? throw new JsonException("manifest.json did not contain an object");
message = string.Empty;
return true;
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
manifest = null!;
message = ex.Message;
return false;
}
}

private static bool TryValidateManifestHeader(ExportManifest manifest, out string message)
{
if (!string.Equals(manifest.FormatVersion, "1", StringComparison.Ordinal))
{
message = $"unsupported format_version `{manifest.FormatVersion}`";
return false;
}

if (manifest.UserVersion < 0 || (manifest.UserVersion & ~DbContext.CurrentSchemaVersion) != 0)
{
message = $"unsupported user_version `{manifest.UserVersion}`";
return false;
}

if (!IsSha256Hex(manifest.DatabaseSha256))
{
message = "database_sha256 is missing or invalid";
return false;
}

message = string.Empty;
return true;
}

private static bool TryValidateImportedManifest(ExportManifest manifest, string dbPath, out string message)
{
var actualSha256 = ComputeSha256(dbPath);
if (!string.Equals(manifest.DatabaseSha256, actualSha256, StringComparison.OrdinalIgnoreCase))
{
message = "database_sha256 does not match codeindex.db";
return false;
}

var actualUserVersion = ReadSqliteUserVersion(dbPath);
if (actualUserVersion != manifest.UserVersion)
{
message = $"manifest user_version `{manifest.UserVersion}` does not match codeindex.db user_version `{actualUserVersion}`";
return false;
}

message = string.Empty;
return true;
}

private static int ReadSqliteUserVersion(string dbPath)
{
using var connection = new SqliteConnection(CreateUnpooledConnectionString(dbPath));
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA user_version";
return Convert.ToInt32(cmd.ExecuteScalar(), CultureInfo.InvariantCulture);
}

private static bool IsSha256Hex(string? value)
{
if (value == null || value.Length != 64)
return false;

foreach (var ch in value)
{
if (!char.IsAsciiHexDigit(ch))
return false;
}

return true;
}

internal static bool TryValidateDatabaseEntrySize(long uncompressedLength, long compressedLength, out string message)
{
if (uncompressedLength < 0 || compressedLength < 0)
{
message = "archive codeindex.db size metadata is invalid";
return false;
}

if (uncompressedLength > MaxImportDatabaseBytes)
{
message = $"archive codeindex.db is too large: {ConsoleUi.FormatBytes(uncompressedLength)} uncompressed exceeds the import limit of {ConsoleUi.FormatBytes(MaxImportDatabaseBytes)}";
return false;
}

if (compressedLength > MaxImportDatabaseBytes)
{
message = $"archive codeindex.db is too large: {ConsoleUi.FormatBytes(compressedLength)} compressed exceeds the import limit of {ConsoleUi.FormatBytes(MaxImportDatabaseBytes)}";
return false;
}

message = string.Empty;
return true;
}

private static void ExtractDatabaseEntryToFile(ZipArchiveEntry dbEntry, string destinationPath)
{
using var source = dbEntry.Open();
using var target = File.Open(destinationPath, FileMode.Create, FileAccess.Write, FileShare.None);
CopyToWithLimit(source, target, MaxImportDatabaseBytes);
}

internal static long CopyToWithLimit(Stream source, Stream target, long maxBytes)
{
var buffer = new byte[ImportCopyBufferSize];
long totalBytes = 0;
int bytesRead;
while ((bytesRead = source.Read(buffer, 0, buffer.Length)) > 0)
{
if (totalBytes > maxBytes - bytesRead)
throw new InvalidDataException($"archive codeindex.db exceeds the import limit of {ConsoleUi.FormatBytes(maxBytes)}.");

target.Write(buffer, 0, bytesRead);
totalBytes += bytesRead;
}

return totalBytes;
}

private static void RewriteImportedProjectRoot(string dbPath, string projectRoot)
{
using var connection = new SqliteConnection(CreateUnpooledConnectionString(dbPath));
Expand Down
54 changes: 54 additions & 0 deletions tests/CodeIndex.Tests/ExportImportCommandRunnerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using CodeIndex.Cli;

namespace CodeIndex.Tests;

public class ExportImportCommandRunnerTests
{
[Fact]
public void TryValidateDatabaseEntrySize_RejectsOversizedUncompressedLength()
{
var ok = ExportImportCommandRunner.TryValidateDatabaseEntrySize(
uncompressedLength: ExportImportCommandRunner.MaxImportDatabaseBytes + 1,
compressedLength: 1,
message: out var message);

Assert.False(ok);
Assert.Contains("uncompressed exceeds the import limit", message);
}

[Fact]
public void TryValidateDatabaseEntrySize_RejectsOversizedCompressedLength()
{
var ok = ExportImportCommandRunner.TryValidateDatabaseEntrySize(
uncompressedLength: 1,
compressedLength: ExportImportCommandRunner.MaxImportDatabaseBytes + 1,
message: out var message);

Assert.False(ok);
Assert.Contains("compressed exceeds the import limit", message);
}

[Fact]
public void CopyToWithLimit_ThrowsBeforeWritingPastLimit()
{
using var source = new MemoryStream([1, 2, 3, 4]);
using var target = new MemoryStream();

var ex = Assert.Throws<InvalidDataException>(() => ExportImportCommandRunner.CopyToWithLimit(source, target, maxBytes: 3));

Assert.Contains("codeindex.db exceeds the import limit", ex.Message);
Assert.Equal(0, target.Length);
}

[Fact]
public void CopyToWithLimit_AllowsExactLimit()
{
using var source = new MemoryStream([1, 2, 3, 4]);
using var target = new MemoryStream();

var copied = ExportImportCommandRunner.CopyToWithLimit(source, target, maxBytes: 4);

Assert.Equal(4, copied);
Assert.Equal([1, 2, 3, 4], target.ToArray());
}
}
Loading
Loading