diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index e0cc867639..d8b56bf77a 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -138,9 +138,9 @@ ownership boundaries so behavior changes remain reviewable and testable. ### Workspaces -`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, and 1024 members. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single`, `default_db_name` is a plain file name that overrides `codeindex.db`, and `shared_ignores` is reserved for shared ignore policy. `cdidx workspace list` and `cdidx workspace status` report member DB paths. +`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single` with unknown values rejected, `default_db_name` is a plain file name that overrides `codeindex.db`, and `shared_ignores` is reserved for shared ignore policy. `cdidx workspace list` and `cdidx workspace status` report member DB paths. -`cdidx workspace use ` writes the active workspace to the per-user config directory. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery. +`cdidx workspace use ` writes an existing manifest member or `default` workspace to the per-user config directory, rejects missing manifest members, and rejects ambiguous member directory names. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery. ### Observability diff --git a/changelog.d/unreleased/3041.fixed.md b/changelog.d/unreleased/3041.fixed.md new file mode 100644 index 0000000000..e1d8b63e2a --- /dev/null +++ b/changelog.d/unreleased/3041.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3041 +affected: + - src/CodeIndex/Cli/WorkspaceManifest.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Workspace manifests now reject unknown `index_strategy` values (#3041)** - typos such as `singel` now fail manifest validation instead of silently using per-member DB behavior. + +## 日本語 + +- **Workspace manifest が未知の `index_strategy` 値を拒否するようになりました (#3041)** - `singel` のような typo は per-member DB 動作へ黙って落ちず、manifest 検証エラーになります。 diff --git a/changelog.d/unreleased/3165.fixed.md b/changelog.d/unreleased/3165.fixed.md new file mode 100644 index 0000000000..e037c000d0 --- /dev/null +++ b/changelog.d/unreleased/3165.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3165 +affected: + - src/CodeIndex/Cli/WorkspaceCommandRunner.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **`workspace use` now rejects ambiguous same-basename members (#3165)** - when multiple manifest members share the requested directory name, the command now reports a bounded candidate list instead of selecting the first match. + +## 日本語 + +- **`workspace use` が同じ basename の曖昧な member を拒否するようになりました (#3165)** - 要求された directory name に一致する manifest member が複数ある場合、先頭を選ばず bounded な候補一覧を含むエラーを返します。 diff --git a/changelog.d/unreleased/3216.fixed.md b/changelog.d/unreleased/3216.fixed.md new file mode 100644 index 0000000000..253e6190f8 --- /dev/null +++ b/changelog.d/unreleased/3216.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3216 +affected: + - src/CodeIndex/Cli/WorkspaceManifest.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Workspace manifests now reject overlong member paths and default DB names (#3216)** - manifest loading now bounds member path strings and `default_db_name` before resolving paths or materializing DB locations. + +## 日本語 + +- **Workspace manifest が長すぎる member path と default DB 名を拒否するようになりました (#3216)** - manifest 読み込み時に path 解決や DB 位置の materialize 前に member path 文字列と `default_db_name` の長さを制限します。 diff --git a/changelog.d/unreleased/3217.fixed.md b/changelog.d/unreleased/3217.fixed.md new file mode 100644 index 0000000000..493fe317dc --- /dev/null +++ b/changelog.d/unreleased/3217.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3217 +affected: + - src/CodeIndex/Cli/WorkspaceCommandRunner.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **`workspace use` now rejects missing manifest members (#3217)** - selecting a listed member that is absent on disk now fails with a usage error instead of persisting an active workspace pointing at the missing root. + +## 日本語 + +- **`workspace use` が存在しない manifest member を拒否するようになりました (#3217)** - manifest にある member がディスク上に存在しない場合、missing root を指す active workspace を保存せず usage error を返します。 diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index fee297141e..bdad8798e2 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -4,6 +4,9 @@ namespace CodeIndex.Cli; internal static class WorkspaceCommandRunner { + private const int MaxAmbiguousMemberCandidates = 5; + private const int MaxAmbiguousMemberPathChars = 160; + internal static int Run(string[] args, JsonSerializerOptions jsonOptions) { var json = args.Contains("--json", StringComparer.Ordinal); @@ -69,11 +72,24 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio if (manifest == null && !useDefault) return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace manifest was not found.", CommandExitCodes.UsageError, "run `cdidx workspace use ` from a manifest member or pass `default`."); - var member = useDefault - ? null - : manifest?.Members.FirstOrDefault(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase)); - if (manifest != null && member == null && !useDefault) - return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member was not found.", CommandExitCodes.UsageError, "run `cdidx workspace list` and pass one of the listed member directory names."); + WorkspaceMember? member = null; + if (manifest != null && !useDefault) + { + var matches = manifest.Members + .Where(m => string.Equals(Path.GetFileName(m.Path), name, StringComparison.OrdinalIgnoreCase)) + .Take(MaxAmbiguousMemberCandidates + 1) + .ToArray(); + + if (matches.Length == 0) + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member was not found.", CommandExitCodes.UsageError, "run `cdidx workspace list` and pass one of the listed member directory names."); + if (matches.Length > 1) + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member name is ambiguous.", CommandExitCodes.UsageError, $"matching members: {FormatAmbiguousMemberCandidates(matches)}. Use unique member directory names in the workspace manifest."); + + member = matches[0]; + } + + if (member is { Exists: false }) + return CommandErrorWriter.WriteJsonOrHuman(json, jsonOptions, "workspace member is missing on disk.", CommandExitCodes.UsageError, "create the missing member directory or run `cdidx workspace list` and choose an existing member."); var root = member?.Path ?? Environment.CurrentDirectory; var dbPath = member?.DbPath ?? DbPathResolver.ResolveForIndex(root, explicitDbPath: null); @@ -85,4 +101,18 @@ private static int Use(string[] args, bool json, JsonSerializerOptions jsonOptio Console.WriteLine($"Active workspace set to {state.Name}: {state.DbPath}"); return CommandExitCodes.Success; } + + private static string FormatAmbiguousMemberCandidates(IReadOnlyList matches) + { + var candidates = matches + .Take(MaxAmbiguousMemberCandidates) + .Select(member => TruncateAmbiguousMemberPath(member.Path)); + var suffix = matches.Count > MaxAmbiguousMemberCandidates ? ", ..." : string.Empty; + return string.Join(", ", candidates) + suffix; + } + + private static string TruncateAmbiguousMemberPath(string path) + => path.Length <= MaxAmbiguousMemberPathChars + ? path + : path[..(MaxAmbiguousMemberPathChars - 3)] + "..."; } diff --git a/src/CodeIndex/Cli/WorkspaceManifest.cs b/src/CodeIndex/Cli/WorkspaceManifest.cs index 864526d9ab..4a30d620cd 100644 --- a/src/CodeIndex/Cli/WorkspaceManifest.cs +++ b/src/CodeIndex/Cli/WorkspaceManifest.cs @@ -29,6 +29,8 @@ internal static class WorkspaceManifestLoader internal const int MaxManifestBytes = 64 * 1024; internal const int MaxManifestDepth = 16; internal const int MaxManifestMembers = 1024; + internal const int MaxManifestMemberPathChars = 4096; + internal const int MaxDefaultDbNameChars = 255; internal static WorkspaceManifest? Find(string startingDirectory) { @@ -71,7 +73,7 @@ internal static WorkspaceManifest Load(string path) }); var element = document.RootElement; - var strategy = ReadString(element, "index_strategy") ?? "per_member"; + var strategy = ValidateIndexStrategy(ReadString(element, "index_strategy") ?? "per_member"); var dbName = ValidateDefaultDbName(ReadString(element, "default_db_name") ?? "codeindex.db"); var rawMembers = ReadMembers(element); @@ -92,8 +94,22 @@ internal static WorkspaceManifest Load(string path) ? value.GetString() : null; + private static string ValidateIndexStrategy(string strategy) + { + if (string.Equals(strategy, "per_member", StringComparison.OrdinalIgnoreCase) + || string.Equals(strategy, "single", StringComparison.OrdinalIgnoreCase)) + { + return strategy; + } + + throw new InvalidDataException($"Workspace manifest index_strategy must be 'per_member' or 'single': {strategy}"); + } + private static string ValidateDefaultDbName(string dbName) { + if (dbName.Length > MaxDefaultDbNameChars) + throw new InvalidDataException($"Workspace manifest default_db_name exceeds the {MaxDefaultDbNameChars} character limit."); + if (string.IsNullOrWhiteSpace(dbName) || dbName is "." or ".." || Path.IsPathRooted(dbName) @@ -123,6 +139,9 @@ private static IReadOnlyList ReadMembers(JsonElement element) if (string.IsNullOrWhiteSpace(value)) continue; + if (value.Length > MaxManifestMemberPathChars) + throw new InvalidDataException($"Workspace manifest member path exceeds the {MaxManifestMemberPathChars} character limit."); + if (members.Count >= MaxManifestMembers) throw new InvalidDataException($"Workspace manifest members exceed the {MaxManifestMembers} member limit."); diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 23543035d5..b1df9d03c5 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -106,6 +106,30 @@ public void WorkspaceManifestLoader_Load_RejectsTooManyMembers() } } + [Fact] + public void WorkspaceManifestLoader_Load_RejectsOverlongMemberPath() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_member_length"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + var member = new string('a', WorkspaceManifestLoader.MaxManifestMemberPathChars + 1); + File.WriteAllText(manifestPath, $$""" + { + "members": [{{JsonSerializer.Serialize(member)}}] + } + """); + + var ex = Assert.Throws(() => WorkspaceManifestLoader.Load(manifestPath)); + + Assert.Contains($"{WorkspaceManifestLoader.MaxManifestMemberPathChars} character limit", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceManifestLoader_Load_RejectsRootedMemberPath() { @@ -153,6 +177,30 @@ public void WorkspaceManifestLoader_Load_RejectsEscapingMemberPath() } } + [Fact] + public void WorkspaceManifestLoader_Load_RejectsOverlongDefaultDbName() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_db_name_length"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + var dbName = new string('a', WorkspaceManifestLoader.MaxDefaultDbNameChars + 1); + File.WriteAllText(manifestPath, $$""" + { + "default_db_name": {{JsonSerializer.Serialize(dbName)}} + } + """); + + var ex = Assert.Throws(() => WorkspaceManifestLoader.Load(manifestPath)); + + Assert.Contains($"{WorkspaceManifestLoader.MaxDefaultDbNameChars} character limit", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceManifestLoader_Load_RejectsAbsoluteDefaultDbName() { @@ -230,6 +278,30 @@ public void WorkspaceManifestLoader_Load_AcceptsUtf8BomManifest() } } + [Fact] + public void WorkspaceManifestLoader_Load_RejectsUnknownIndexStrategy() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_manifest_index_strategy"); + try + { + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + File.WriteAllText(manifestPath, """ + { + "members": ["src/A"], + "index_strategy": "singel" + } + """); + + var ex = Assert.Throws(() => WorkspaceManifestLoader.Load(manifestPath)); + + Assert.Contains("index_strategy must be 'per_member' or 'single'", ex.Message); + } + finally + { + TestProjectHelper.DeleteDirectory(root); + } + } + [Fact] public void WorkspaceErrors_HonorJsonFlag() { @@ -531,6 +603,76 @@ public void WorkspaceUse_RejectsUnknownManifestMember() } } + [Fact] + public void WorkspaceUse_RejectsMissingManifestMember() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_missing"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_missing_config"); + try + { + File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """{ "members": ["src/Missing"] }"""); + using var env = EnvironmentVariableScope.Capture("XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, _, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["use", "Missing"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("workspace member is missing on disk", stderr); + Assert.False(File.Exists(ActiveWorkspace.StatePath)); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + TestProjectHelper.DeleteDirectory(configHome); + } + } + + [Fact] + public void WorkspaceUse_RejectsAmbiguousSameBasenameMembers() + { + var root = TestProjectHelper.CreateTempProject("cdidx_workspace_use_ambiguous"); + var configHome = TestProjectHelper.CreateTempProject("cdidx_workspace_use_ambiguous_config"); + try + { + Directory.CreateDirectory(Path.Combine(root, "src", "App")); + Directory.CreateDirectory(Path.Combine(root, "tests", "App")); + File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), """{ "members": ["src/App", "tests/App"] }"""); + using var env = EnvironmentVariableScope.Capture("XDG_CONFIG_HOME"); + Environment.SetEnvironmentVariable("XDG_CONFIG_HOME", configHome); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, _, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run(["use", "App"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("workspace member name is ambiguous", stderr); + Assert.Contains(Path.Combine("src", "App"), stderr); + Assert.Contains(Path.Combine("tests", "App"), stderr); + Assert.False(File.Exists(ActiveWorkspace.StatePath)); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + finally + { + TestProjectHelper.DeleteDirectory(root); + TestProjectHelper.DeleteDirectory(configHome); + } + } + [Fact] public void WorkspaceUse_RejectsNamedWorkspaceWithoutManifest() {