diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 6e79f32b9..f681ce65c 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -206,7 +206,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Hotspot candidate-filter alignment is one public database-behavior contract across site, grouped, and file list/count queries. Keep visibility include/exclude filters before ambiguity cardinality, path include/exclude and test suppression after cardinality, authoritative family identity intact, and all six result/count shapes aligned. The structural SQL fixture separately pins the shared candidate plan to its explicit ten-column projection and forbids `SELECT *`. Unused-candidate query coverage must keep the SQL-aware and non-SQL paths aligned on the shared 17-column projection, provisional bucket ordering, scope filters, and parameter binding. Retain one mixed C#/SQL public-behavior fixture that proves SQL reference resolution, C# projection and null fallbacks, list/count agreement, and detailed bucket counts together. A separate mixed partial-family fixture must force the resolver-aware and non-SQL routes with an SQL scope probe plus a visibility-filtered SQL control, then require result/count/detailed-count parity while the shared pre-page predicate suppresses a private member used from a peer part; keep same-file C# suppression and enum-collision scope in their focused fixtures. Unused-symbol classification coverage must preserve surface-tag insertion order and contract-domain precedence when one symbol overlaps generated, contract-member, configuration, exception-metadata, public, and test signals. Keep those surface predicates evaluated once into the shared analysis, retain private/non-public early returns, and assert aligned reason tags, domain tags, and aggregate domain counts. - Checkpoint listing and retention fixtures should exceed the relevant directory cap once and exceed the inspected-file cap on only one checkpoint; multiplying both caps together adds filesystem work without increasing boundary coverage. Checkpoint and restore-backup cleanup dry-run tests must assert both exact deleted/retained paths and the continued existence of every candidate directory. Restore dry-run tests should inject destination free space through `DbCommandRunner.AvailableFreeSpaceForTesting`, verify manifest and regular-file path diagnostics independently, and prove that no restore staging or backup directory is created. + Checkpoint listing and retention fixtures should exceed the relevant directory cap once and exceed the inspected-file cap on only one checkpoint; multiplying both caps together adds filesystem work without increasing boundary coverage. Checkpoint and restore-backup cleanup dry-run tests must assert both exact deleted/retained paths and the continued existence of every candidate directory. Restore dry-run tests should inject destination free space through `DbCommandRunner.AvailableFreeSpaceForTesting`, verify manifest and regular-file path diagnostics independently, and prove that no restore staging or backup directory is created. Checkpoint-name coverage must round-trip representative accepted names through create and restore dry-run, reject CR, LF, CRLF, NUL, and another C0 control at create/restore/delete entry points with the shared usage contract, and prove that rejection creates no checkpoint artifacts. WAL checkpoint contention fixtures must establish a real read transaction before a later writer commit, then lower `busy_timeout` to keep the assertion bounded without fixed sleeps. Permission/error-path coverage may throw from `DbContext.WalCheckpointTruncateExecutedForTesting`; restore the previous `AsyncLocal` value in `finally` so parallel test state cannot leak. - `ConcurrencyTests.cs` WAL snapshot and shared-writer stress tests. The concurrent reader/writer @@ -1283,7 +1283,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" hotspot candidate filter の整合性は、site / grouped / file の list / count 全6 query に共通する public database behavior 契約です。visibility include / exclude は ambiguity cardinality より前、path include / exclude と test suppression は cardinality より後に適用し、authoritative family identity と全6形状の結果・件数を一致させてください。structural SQL fixture は別途、共通 candidate plan の明示的な10列 projection と `SELECT *` 禁止を固定します。 unused candidate query の coverage では、SQL-aware / non-SQL 経路で共通の17列 projection、provisional bucket 順、scope filter、parameter binding を一致させてください。mixed C#/SQL の public behavior fixture 1つで SQL reference resolution、C# projection と null fallback、list/count の一致、詳細 bucket count をまとめて固定します。別の mixed partial-family fixture では、SQL scope probe と visibility filter で除外される SQL control によって resolver-aware / non-SQL の両経路を強制し、peer part から使用される private member を共有の page 前 predicate が抑制した状態で、result / count / detailed-count の一致を必須にしてください。same-file C# suppression と enum collision scope は焦点を絞った既存 fixture で維持します。 unused symbol classification の coverage では、1つの symbol が generated、contract member、configuration、exception metadata、public、test の各 signal に重なる場合も、surface tag の追加順と contract domain の優先順位を維持してください。これらの surface predicate は共有 analysis へ1度だけ評価し、private / non-public の early return を保ったまま、reason tag、domain tag、domain 集計件数の一致を assertion します。 - checkpoint listing / retention fixture は対象の directory cap を 1 件だけ超え、inspected-file cap は 1 checkpoint だけで超えてください。両方の cap を掛け合わせても boundary coverage は増えず、filesystem work だけが増えます。checkpoint / restore-backup cleanup の dry-run test では、削除予定・保持予定の正確な path と、全候補 directory が引き続き存在することの両方を検証してください。restore dry-run test は `DbCommandRunner.AvailableFreeSpaceForTesting` で destination free space を注入し、manifest と regular-file path の diagnostic を独立に検証し、restore staging directory や backup directory が作られないことを固定します。 + checkpoint listing / retention fixture は対象の directory cap を 1 件だけ超え、inspected-file cap は 1 checkpoint だけで超えてください。両方の cap を掛け合わせても boundary coverage は増えず、filesystem work だけが増えます。checkpoint / restore-backup cleanup の dry-run test では、削除予定・保持予定の正確な path と、全候補 directory が引き続き存在することの両方を検証してください。restore dry-run test は `DbCommandRunner.AvailableFreeSpaceForTesting` で destination free space を注入し、manifest と regular-file path の diagnostic を独立に検証し、restore staging directory や backup directory が作られないことを固定します。checkpoint 名の coverage では、代表的な受理名を create から restore dry-run まで round-trip し、CR、LF、CRLF、NUL、その他の C0 制御文字を create / restore / delete の全入口で共通 usage contract により拒否し、拒否時に checkpoint artifact が作られないことを証明してください。 WAL checkpoint contention fixture は後続 writer commit より先に実際の read transaction を確立し、固定 sleep を使わず assertion を bounded にするため `busy_timeout` を短くします。permission / error path の coverage では `DbContext.WalCheckpointTruncateExecutedForTesting` から exception を throw できますが、parallel test state を漏らさないよう、以前の `AsyncLocal` 値を `finally` で復元してください。 - `ConcurrencyTests.cs` WAL snapshot と shared-writer の stress test。concurrent reader/writer snapshot diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 96e19e664..6bd552087 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1340,7 +1340,7 @@ Failures from `vacuum`, `backfill-fold`, `optimize` (including `index --optimize `db schema` keeps the current full schema dump by default for support bundles. Add `--summary-only` to return only object counts, combine `--type ` and `--name ` for an exact projection, and use `--limit`, `--max-sql-chars`, and `--exclude-internal` to keep schema diagnostics bounded. -`db checkpoint --dry-run` separates source DB/WAL/SHM files and bytes from every planned output without creating the checkpoint directory. The plan includes the versioned `manifest.txt`, its SHA-256, estimated final bytes, destination/conflict status and policy, sidecar/compression/metadata policy, and remaining uncertainty. The metadata policy is `owner_only_files_and_directories` on POSIX and `inherited_windows_acls` on Windows. Execution creates a fresh plan from the current sources, verifies source metadata and SHA-256 before and after copying, verifies every output, and refuses to publish if the inputs drift. Non-regular DB/WAL/SHM inputs such as Unix FIFOs are rejected before hashing. A source database named `manifest.txt` (including an equivalent case variant on a case-insensitive filesystem) conflicts with the generated manifest, so the plan reports `ready: false` and execution refuses without creating a checkpoint. Import, checkpoint restore, and managed-backup restore create a verified standalone rollback snapshot before replacing an existing DB. `db restore-backups --list` returns each managed ID and provenance; `--restore ` validates its bounded manifest, SHA-256, supported schema stamp, and free space before an atomic replacement that rolls back on failure. Add `--dry-run` to perform the same validations and preview the pre-restore backup without mutation. `--no-backup` is an explicit opt-out from creating rollback material and should be reserved for cases where losing the current DB is acceptable. A checkpoint name must be a non-blank single file name of at most 128 characters: it cannot be `.` or `..`, contain a directory separator, or contain characters that the operating system rejects in file names. Invalid names are input errors (`E010_USAGE_ERROR`), not database or storage failures. +`db checkpoint --dry-run` separates source DB/WAL/SHM files and bytes from every planned output without creating the checkpoint directory. The plan includes the versioned `manifest.txt`, its SHA-256, estimated final bytes, destination/conflict status and policy, sidecar/compression/metadata policy, and remaining uncertainty. The metadata policy is `owner_only_files_and_directories` on POSIX and `inherited_windows_acls` on Windows. Execution creates a fresh plan from the current sources, verifies source metadata and SHA-256 before and after copying, verifies every output, and refuses to publish if the inputs drift. Non-regular DB/WAL/SHM inputs such as Unix FIFOs are rejected before hashing. A source database named `manifest.txt` (including an equivalent case variant on a case-insensitive filesystem) conflicts with the generated manifest, so the plan reports `ready: false` and execution refuses without creating a checkpoint. Import, checkpoint restore, and managed-backup restore create a verified standalone rollback snapshot before replacing an existing DB. `db restore-backups --list` returns each managed ID and provenance; `--restore ` validates its bounded manifest, SHA-256, supported schema stamp, and free space before an atomic replacement that rolls back on failure. Add `--dry-run` to perform the same validations and preview the pre-restore backup without mutation. `--no-backup` is an explicit opt-out from creating rollback material and should be reserved for cases where losing the current DB is acceptable. A checkpoint name must be a non-blank single file name of at most 128 characters: it cannot be `.` or `..`, contain a directory separator or C0 control character (including CR, LF, and NUL), or contain characters that the operating system rejects in file names. Invalid names are input errors (`E010_USAGE_ERROR` / `usage`), not database or storage failures, and are rejected before checkpoint artifacts are created. Here, drift refusal means drift detected through the final pre-publication validation. The `uncertainty` field explicitly notes that a source can still change after that validation; the already-copied outputs remain verified against the immutable plan. @@ -4880,7 +4880,7 @@ DB を read-only で開いて SQLite の `PRAGMA integrity_check` を実行し `db schema` は support bundle 向けに、既定では従来どおり full schema dump を維持します。`--summary-only` を付けると object 件数だけを返し、`--type ` と `--name ` を組み合わせると exact projection を適用できます。schema diagnostics を小さく保つには `--limit`、`--max-sql-chars`、`--exclude-internal` を使います。 -`db checkpoint --dry-run` は checkpoint directory を作らずに、source DB/WAL/SHM file と byte 数を、作成予定の全 output から分けて報告します。plan には version 付き `manifest.txt`、その SHA-256、最終 byte 数の見積もり、destination/conflict の状態と policy、sidecar/compression/metadata policy、残る不確実性が含まれます。metadata policy は POSIX では `owner_only_files_and_directories`、Windows では `inherited_windows_acls` です。実行時は現在の source から新しい plan を作成し、copy 前後の source metadata と SHA-256、および全 output を検証して、入力が変化した場合は publish を拒否します。Unix FIFO など regular file ではない DB/WAL/SHM input は hash 読み取り前に拒否します。source database 名が `manifest.txt`(case-insensitive filesystem 上の同等な大小文字違いを含む)の場合は生成 manifest と衝突するため、plan は `ready: false` を報告し、実行は checkpoint を作成せず拒否します。import、checkpoint restore、managed-backup restore は既存 DB の置換前に検証済み standalone rollback snapshot を作成します。`db restore-backups --list` は managed ID と provenance を返し、`--restore ` は bounded manifest、SHA-256、対応する schema stamp、free space を検証してから、失敗時に rollback する atomic replacement を実行します。`--dry-run` を付けると、同じ検証と pre-restore backup の予定を DB 無変更で確認できます。`--no-backup` は rollback material 作成の明示的な opt-out であり、現在の DB を失ってもよい場合にだけ使用してください。checkpoint 名は空白だけではない 128 文字以下の単一 file 名でなければならず、`.`、`..`、directory separator、または OS が file 名で拒否する文字は使用できません。不正な名前は database / storage 障害ではなく入力エラー (`E010_USAGE_ERROR`) として扱われます。 +`db checkpoint --dry-run` は checkpoint directory を作らずに、source DB/WAL/SHM file と byte 数を、作成予定の全 output から分けて報告します。plan には version 付き `manifest.txt`、その SHA-256、最終 byte 数の見積もり、destination/conflict の状態と policy、sidecar/compression/metadata policy、残る不確実性が含まれます。metadata policy は POSIX では `owner_only_files_and_directories`、Windows では `inherited_windows_acls` です。実行時は現在の source から新しい plan を作成し、copy 前後の source metadata と SHA-256、および全 output を検証して、入力が変化した場合は publish を拒否します。Unix FIFO など regular file ではない DB/WAL/SHM input は hash 読み取り前に拒否します。source database 名が `manifest.txt`(case-insensitive filesystem 上の同等な大小文字違いを含む)の場合は生成 manifest と衝突するため、plan は `ready: false` を報告し、実行は checkpoint を作成せず拒否します。import、checkpoint restore、managed-backup restore は既存 DB の置換前に検証済み standalone rollback snapshot を作成します。`db restore-backups --list` は managed ID と provenance を返し、`--restore ` は bounded manifest、SHA-256、対応する schema stamp、free space を検証してから、失敗時に rollback する atomic replacement を実行します。`--dry-run` を付けると、同じ検証と pre-restore backup の予定を DB 無変更で確認できます。`--no-backup` は rollback material 作成の明示的な opt-out であり、現在の DB を失ってもよい場合にだけ使用してください。checkpoint 名は空白だけではない 128 文字以下の単一 file 名でなければならず、`.`、`..`、directory separator、C0 制御文字(CR、LF、NUL を含む)、または OS が file 名で拒否する文字は使用できません。不正な名前は checkpoint artifact の作成前に拒否され、database / storage 障害ではなく入力エラー (`E010_USAGE_ERROR` / `usage`) として扱われます。 ここで drift の拒否とは、publish 前の最終検証までに検出した drift を指します。`uncertainty` field は、その検証後にも source が変化し得ることを明示しますが、copy 済み output は immutable plan に対して検証されたままです。 diff --git a/changelog.d/unreleased/5082.security.md b/changelog.d/unreleased/5082.security.md new file mode 100644 index 000000000..6cdb0e9e8 --- /dev/null +++ b/changelog.d/unreleased/5082.security.md @@ -0,0 +1,22 @@ +--- +category: security +issues: + - 5082 +affected: + - src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs + - src/CodeIndex/Cli/DbCommandRunner.Maintenance.cs + - src/CodeIndex/Cli/DbCommandRunner.Restore.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/DbCommandRunnerTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Database checkpoint names now reject C0 control characters before writing artifacts (#5082)** — checkpoint creation, restore, and deletion consistently return `E010_USAGE_ERROR` / `usage` for names containing CR, LF, NUL, or any other C0 control, preventing successful creation of manifests that cannot be restored. + +## 日本語 + +- **database checkpoint 名の C0 制御文字を artifact 作成前に拒否するようにしました (#5082)** — CR、LF、NUL、その他の C0 制御文字を含む名前に対し、checkpoint 作成・restore・delete が一貫して `E010_USAGE_ERROR` / `usage` を返し、restore 不能な manifest の作成成功を防ぎます。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 7a79af805..7c342d20c 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -221,6 +221,7 @@ private static readonly (string Command, string Note)[] CommandUsageNotes = ("recipes-list", "Example: `cdidx recipes list --names --json`."), ("db", "schema defaults to the full sqlite_master dump for support bundles; use --summary-only, --limit, --max-sql-chars, and --exclude-internal for bounded diagnostics."), ("db", "checkpoint --dry-run separates source DB/WAL/SHM bytes from every planned output, including the versioned manifest, its SHA-256, estimated final bytes, destination/conflict policy, and uncertainty."), + ("db", "checkpoint names must be non-blank single file names of at most 128 characters and cannot contain C0 control characters, directory separators, or platform-invalid file-name characters."), ("db", "checkpoint creates a filesystem snapshot next to the DB; restore creates a verified managed rollback backup before replacing an existing DB unless --no-backup is explicit."), ("db", "restore --dry-run validates the checkpoint manifest, regular-file paths, rollback-backup policy, and destination free space without replacing the DB."), ("db", "checkpoints --delete and --prune remove snapshots; add --dry-run to report exact deleted/retained paths without mutation."), diff --git a/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs b/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs index bf18bc6bd..1398adddf 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.FileOperations.cs @@ -16,6 +16,7 @@ private static void ValidateCheckpointName(string name) { if (string.IsNullOrWhiteSpace(name) || name is "." or ".." + || ContainsC0ControlCharacter(name) || name.IndexOfAny(InvalidCheckpointNameChars) >= 0 || name.Contains(Path.DirectorySeparatorChar) || (Path.AltDirectorySeparatorChar != '\0' && name.Contains(Path.AltDirectorySeparatorChar))) @@ -25,6 +26,20 @@ private static void ValidateCheckpointName(string name) throw new ArgumentException($"checkpoint name is too long ({name.Length} characters; max {MaxCheckpointNameLength}): {FormatCheckpointNameForDiagnostic(name)}"); } + private static bool ContainsC0ControlCharacter(string name) + { + foreach (var character in name) + { + if (character <= '\u001f') + return true; + } + + return false; + } + + private static string CheckpointNameUsageHint + => $"Use a non-blank single file name of at most {MaxCheckpointNameLength} characters; do not use `.` or `..`, directory separators, C0 control characters, or characters invalid in file names on this operating system."; + private static string FormatCheckpointNameForDiagnostic(string name) => ConsoleUi.FormatBoundedValue(name, CheckpointNameDiagnosticTextLimit); diff --git a/src/CodeIndex/Cli/DbCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/DbCommandRunner.Maintenance.cs index 8ab84d154..98b167e23 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.Maintenance.cs @@ -217,7 +217,7 @@ private static int RunCheckpoint(DbCommandOptions options, JsonSerializerOptions safeMessage, isInputError ? CommandExitCodes.UsageError : CommandExitCodes.DatabaseError, isInputError - ? $"Use a non-blank single file name of at most {MaxCheckpointNameLength} characters; do not use `.` or `..`, directory separators, or characters invalid in file names on this operating system." + ? CheckpointNameUsageHint : "Ensure the database and checkpoint directory are writable, then retry `cdidx db checkpoint`.", isInputError ? CommandErrorCodes.UsageError : CommandErrorCodes.DbError, category: isInputError ? null : DiagnosticRedactor.ClassifyException(ex)); @@ -346,7 +346,7 @@ private static int RunDeleteCheckpoint( jsonOptions, CommandErrorWriter.FormatSanitizedExceptionMessage(ex), CommandExitCodes.UsageError, - $"Use a non-blank single file name of at most {MaxCheckpointNameLength} characters.", + CheckpointNameUsageHint, CommandErrorCodes.UsageError); } diff --git a/src/CodeIndex/Cli/DbCommandRunner.Restore.cs b/src/CodeIndex/Cli/DbCommandRunner.Restore.cs index 40d6406db..6fd551af3 100644 --- a/src/CodeIndex/Cli/DbCommandRunner.Restore.cs +++ b/src/CodeIndex/Cli/DbCommandRunner.Restore.cs @@ -17,6 +17,21 @@ private static int RunRestore( { if (string.IsNullOrWhiteSpace(options.Name)) return WriteCommandError(options.Json, jsonOptions, "restore requires a checkpoint name", CommandExitCodes.UsageError, "Use `cdidx db restore --db `.", CommandErrorCodes.UsageError); + try + { + ValidateCheckpointName(options.Name); + } + catch (ArgumentException ex) + { + return WriteCommandError( + options.Json, + jsonOptions, + CommandErrorWriter.FormatSanitizedExceptionMessage(ex), + CommandExitCodes.UsageError, + CheckpointNameUsageHint, + CommandErrorCodes.UsageError); + } + if (!ValidateWritableFileDb(options, jsonOptions, "restore", out var fullDbPath, out var validationExitCode)) return validationExitCode; diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index f8e78ff60..ab92a940b 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -384,6 +384,20 @@ public void PrintCommandUsage_ExcerptDocumentsLineOnlyFocus_Issue4747() Assert.Contains("--focus-length still requires --focus-column", stdout); } + [Fact] + public void PrintCommandUsage_DbDocumentsCheckpointNameControls_Issue5082() + { + var (_, stdout, stderr) = ConsoleCapture.Capture(() => + { + ConsoleUi.PrintCommandUsage("db"); + return 0; + }); + + Assert.Equal(string.Empty, stderr); + Assert.Contains("checkpoint names must be non-blank single file names", stdout); + Assert.Contains("cannot contain C0 control characters", stdout); + } + [Fact] public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() { diff --git a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs index 6284da8ec..698cb0608 100644 --- a/tests/CodeIndex.Tests/DbCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/DbCommandRunnerTests.cs @@ -881,26 +881,39 @@ public void Run_RestoreMissingCheckpoint_JsonUsesCheckpointErrorCode_Issue4337() } } - [Fact] - public void Run_CheckpointHyphenatedName_DryRunAndWriteUseSameName_Issue4337() + [Theory] + [InlineData("round7-real")] + [InlineData("checkpoint 01")] + [InlineData("checkpoint_01")] + [InlineData("チェックポイント")] + public void Run_CheckpointAcceptedNames_RoundTripThroughRestoreDryRun_Issues4337And5082(string name) { - var root = TestProjectHelper.CreateTempProject("cdidx_db_checkpoint_hyphen_4337"); + var root = TestProjectHelper.CreateTempProject("cdidx_db_checkpoint_name_roundtrip_5082"); var dbPath = Path.Combine(root, "codeindex.db"); try { InitializeEmptyDb(dbPath); + DbCommandRunner.AvailableFreeSpaceForTesting = _ => long.MaxValue; - var (dryRunExit, dryRunJson) = RunAndCaptureJson(["checkpoint", "round7-real", "--dry-run", "--db", dbPath, "--json"]); - var (writeExit, writeJson) = RunAndCaptureJson(["checkpoint", "round7-real", "--db", dbPath, "--json"]); + var (dryRunExit, dryRunJson) = RunAndCaptureJson(["checkpoint", name, "--dry-run", "--db", dbPath, "--json"]); + var (writeExit, writeJson) = RunAndCaptureJson(["checkpoint", name, "--db", dbPath, "--json"]); + var (restoreExit, restoreJson) = RunAndCaptureJson(["restore", name, "--dry-run", "--db", dbPath, "--json"]); Assert.Equal(CommandExitCodes.Success, dryRunExit); Assert.Equal(CommandExitCodes.Success, writeExit); - Assert.Equal("round7-real", dryRunJson.GetProperty("name").GetString()); - Assert.Equal("round7-real", writeJson.GetProperty("name").GetString()); - Assert.True(Directory.Exists(writeJson.GetProperty("checkpoint_path").GetString())); + Assert.Equal(CommandExitCodes.Success, restoreExit); + Assert.Equal(name, dryRunJson.GetProperty("name").GetString()); + Assert.Equal(name, writeJson.GetProperty("name").GetString()); + Assert.Equal(name, restoreJson.GetProperty("name").GetString()); + Assert.True(restoreJson.GetProperty("ready").GetBoolean()); + Assert.True(restoreJson.GetProperty("manifest_valid").GetBoolean()); + var checkpointPath = writeJson.GetProperty("checkpoint_path").GetString()!; + Assert.True(Directory.Exists(checkpointPath)); + Assert.Contains($"name={name}{Environment.NewLine}", File.ReadAllText(Path.Combine(checkpointPath, "manifest.txt")), StringComparison.Ordinal); } finally { + DbCommandRunner.AvailableFreeSpaceForTesting = null; DeleteWorkDirectory(root); } } @@ -1188,6 +1201,46 @@ public void Run_CheckpointTraversalName_JsonUsesUsageErrorAndSyntaxHint_Issue447 } } + [Theory] + [InlineData("\r")] + [InlineData("\n")] + [InlineData("\r\n")] + [InlineData("\0")] + [InlineData("\u001f")] + public void Run_CheckpointNameEntryPoints_RejectC0ControlsBeforeCreatingArtifacts_Issue5082(string controlCharacter) + { + var root = TestProjectHelper.CreateTempProject("cdidx_db_checkpoint_control_name_5082"); + var dbPath = Path.Combine(root, "codeindex.db"); + var checkpointRoot = dbPath + ".checkpoints"; + var name = "before" + controlCharacter + "after"; + try + { + InitializeEmptyDb(dbPath); + + foreach (var command in new[] + { + new[] { "checkpoint", name, "--db", dbPath, "--json" }, + new[] { "restore", name, "--dry-run", "--db", dbPath, "--json" }, + new[] { "checkpoints", "--delete", name, "--dry-run", "--db", dbPath, "--json" }, + }) + { + var (exitCode, json) = RunAndCaptureJson(command); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal("error", json.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, json.GetProperty("error_code").GetString()); + Assert.Equal("usage", json.GetProperty("category").GetString()); + Assert.Contains("invalid checkpoint name", json.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Contains("C0 control characters", json.GetProperty("hint").GetString(), StringComparison.Ordinal); + Assert.False(Directory.Exists(checkpointRoot)); + } + } + finally + { + DeleteWorkDirectory(root); + } + } + [Fact] public void Run_CheckpointRejectsOversizedNameBeforePathConstruction_Issue3124() {