From ca305f0ff17a0b80938db401ebc2882b7354d7d7 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 20:16:20 +0900 Subject: [PATCH] fix(repository-api): validate operation reference siblings --- .../src/repository_openapi_typescript.rs | 55 +++++++++++-------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 3f00d7741..dc7a9d12e 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -208,13 +208,25 @@ fn json_content_schema<'a>( .ok_or_else(|| GenerationError::invalid(format!("{context} is missing its JSON schema"))) } +fn schema_reference<'a>(value: &'a Value, context: &str) -> Result<&'a str, GenerationError> { + const ALLOWED_REFERENCE_SIBLINGS: &[&str] = + &["$ref", "$comment", "deprecated", "description", "title"]; + let schema = object(value, context)?; + 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}`" + ))); + } + schema.get("$ref").and_then(Value::as_str).ok_or_else(|| { + GenerationError::invalid(format!("{context} must use a string schema reference")) + }) +} + fn require_schema_ref(value: &Value, expected: &str, context: &str) -> Result<(), GenerationError> { - let reference = object(value, context)? - .get("$ref") - .and_then(Value::as_str) - .ok_or_else(|| { - GenerationError::invalid(format!("{context} must use a schema reference")) - })?; + let reference = schema_reference(value, context)?; if reference != format!("{SCHEMA_REF_PREFIX}{expected}") { return Err(GenerationError::invalid(format!( "{context} must reference `{expected}`, found `{reference}`" @@ -250,10 +262,8 @@ fn collect_schema_references( ) -> Result<(), GenerationError> { match value { Value::Object(object) => { - if let Some(reference) = object.get("$ref") { - let reference = reference.as_str().ok_or_else(|| { - GenerationError::invalid(format!("{context} contains a non-string $ref")) - })?; + if object.contains_key("$ref") { + let reference = schema_reference(value, context)?; let Some(name) = reference.strip_prefix(SCHEMA_REF_PREFIX) else { return Err(GenerationError::invalid(format!( "{context} contains unsupported reference `{reference}`" @@ -277,20 +287,8 @@ 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")) - })?; + if schema.contains_key("$ref") { + let reference = schema_reference(value, context)?; return reference .strip_prefix(SCHEMA_REF_PREFIX) .map(str::to_owned) @@ -587,6 +585,15 @@ mod tests { assert!(error.to_string().contains("unsupported `$ref` sibling")); } + #[test] + fn generator_rejects_lossy_operation_reference_siblings() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["paths"]["/api/repositories"]["get"]["responses"]["200"]["content"]["application/json"] + ["schema"]["maxItems"] = 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();