fix(repository-api): reject lossy schemas and revision ranges

This commit is contained in:
2026-09-18 20:10:52 +09:00
parent 50ba3ab2d5
commit 9588b1e2c8
3 changed files with 53 additions and 2 deletions
@@ -278,6 +278,16 @@ fn collect_schema_references(
fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError> {
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();
@@ -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`,
@@ -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<string, unknown>;
const negativeItems = negative.items as Array<Record<string, unknown>>;
negativeItems[0].source_revision = -1;
assertThrows(
() => parseRepositoryListResponse(negative),
"must be between 0 and Number.MAX_SAFE_INTEGER",
);
const unsafe = structuredClone(repositoryList) as Record<string, unknown>;
const unsafeItems = unsafe.items as Array<Record<string, unknown>>;
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,