From 9588b1e2c89466969bae11a02121ef057eefa629 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 20:10:52 +0900 Subject: [PATCH] fix(repository-api): reject lossy schemas and revision ranges --- .../src/repository_openapi_typescript.rs | 19 +++++++++++++++++++ .../src/lib/workspace/api/workspace-model.ts | 18 ++++++++++++++++-- web/workspace/tests/workspace-model.test.ts | 18 ++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index a10a7491b..3f00d7741 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -278,6 +278,16 @@ fn collect_schema_references( fn render_schema(value: &Value, context: &str) -> Result { let schema = object(value, context)?; if let Some(reference) = schema.get("$ref") { + const ALLOWED_REFERENCE_SIBLINGS: &[&str] = + &["$ref", "$comment", "deprecated", "description", "title"]; + if let Some(keyword) = schema + .keys() + .find(|keyword| !ALLOWED_REFERENCE_SIBLINGS.contains(&keyword.as_str())) + { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported `$ref` sibling `{keyword}`" + ))); + } let reference = reference.as_str().ok_or_else(|| { GenerationError::invalid(format!("{context} contains a non-string $ref")) })?; @@ -568,6 +578,15 @@ mod tests { assert!(error.to_string().contains("ambiguous anyOf")); } + #[test] + fn generator_rejects_lossy_reference_siblings() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["source"]["maxLength"] = + serde_json::json!(1); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("unsupported `$ref` sibling")); + } + #[test] fn generator_is_bounded_to_the_repository_operation_contract() { let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index 1a4ce55f1..3f6d51cdb 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -131,6 +131,14 @@ function repositoryArray(value: unknown, path: string): unknown[] { return parsed; } +function repositorySourceRevision(value: unknown, path: string): number { + const parsed = integer(value, path); + if (parsed < 0) { + throw new Error(`${path} must be between 0 and Number.MAX_SAFE_INTEGER`); + } + return parsed; +} + function optionalNullableString( value: unknown, path: string, @@ -279,7 +287,10 @@ function repositorySummary(value: unknown, path: string): RepositorySummary { kind: repositoryString(item.kind, `${path}.kind`), provider: repositoryString(item.provider, `${path}.provider`), source: repositorySource(item.source, `${path}.source`), - source_revision: integer(item.source_revision, `${path}.source_revision`), + source_revision: repositorySourceRevision( + item.source_revision, + `${path}.source_revision`, + ), source_fingerprint: repositoryString( item.source_fingerprint, `${path}.source_fingerprint`, @@ -368,7 +379,10 @@ function workspaceRepositoryRecord( provider: nullableString(item.provider, `${path}.provider`), source: repositorySource(item.source, `${path}.source`), default_ref: nullableString(item.default_ref, `${path}.default_ref`), - source_revision: integer(item.source_revision, `${path}.source_revision`), + source_revision: repositorySourceRevision( + item.source_revision, + `${path}.source_revision`, + ), source_fingerprint: repositoryString( item.source_fingerprint, `${path}.source_fingerprint`, diff --git a/web/workspace/tests/workspace-model.test.ts b/web/workspace/tests/workspace-model.test.ts index 02d380050..de7aa3e30 100644 --- a/web/workspace/tests/workspace-model.test.ts +++ b/web/workspace/tests/workspace-model.test.ts @@ -74,6 +74,24 @@ Deno.test("stale repository aliases fail closed at the JSON boundary", () => { ); }); +Deno.test("repository source revisions enforce the OpenAPI integer range", () => { + const negative = structuredClone(repositoryList) as Record; + const negativeItems = negative.items as Array>; + negativeItems[0].source_revision = -1; + assertThrows( + () => parseRepositoryListResponse(negative), + "must be between 0 and Number.MAX_SAFE_INTEGER", + ); + + const unsafe = structuredClone(repositoryList) as Record; + const unsafeItems = unsafe.items as Array>; + unsafeItems[0].source_revision = Number.MAX_SAFE_INTEGER + 1; + assertThrows( + () => parseRepositoryListResponse(unsafe), + "must be a safe integer", + ); +}); + Deno.test("repository create/error responses use the generated strict contract", () => { const created = parseCreateWorkspaceRepositoryResponse({ workspace_id: repositoryList.workspace_id,