fix: fail closed on lossy Repository schemas

This commit is contained in:
2026-09-18 21:02:37 +09:00
5 changed files with 292 additions and 15 deletions
@@ -4,8 +4,7 @@ use serde_json::{Map, Value};
pub const GENERATOR_NAME: &str = "yoi-repository-openapi-typescript";
pub const GENERATOR_VERSION: &str = "1";
pub const GENERATOR_OPTIONS: &str =
"repository-operation-schema-closure,openapi-3.1,strict-safe-integers,sorted-output";
pub const GENERATOR_OPTIONS: &str = "repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output";
pub const INPUT_PATH: &str = "openapi/server-api.json";
pub const OUTPUT_PATH: &str = "web/workspace/src/lib/generated/repository-api.ts";
@@ -285,6 +284,49 @@ fn collect_schema_references(
Ok(())
}
fn ensure_schema_keywords(
schema: &Map<String, Value>,
allowed: &[&str],
context: &str,
) -> Result<(), GenerationError> {
const ALLOWED_ANNOTATIONS: &[&str] = &[
"$comment",
"default",
"deprecated",
"description",
"example",
"examples",
"title",
];
if let Some(keyword) = schema.keys().find(|keyword| {
!allowed.contains(&keyword.as_str()) && !ALLOWED_ANNOTATIONS.contains(&keyword.as_str())
}) {
return Err(GenerationError::invalid(format!(
"{context} contains unsupported schema keyword `{keyword}`"
)));
}
Ok(())
}
fn literal_matches_type(kind: &str, value: &Value) -> bool {
matches!(
(kind, value),
("string", Value::String(_)) | ("boolean", Value::Bool(_)) | ("null", Value::Null)
)
}
fn literal_schema_matches_type(schema: &Map<String, Value>, kind: &str) -> bool {
if let Some(constant) = schema.get("const") {
return literal_matches_type(kind, constant);
}
schema
.get("enum")
.and_then(Value::as_array)
.is_some_and(|values| {
!values.is_empty() && values.iter().all(|value| literal_matches_type(kind, value))
})
}
fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError> {
let schema = object(value, context)?;
if schema.contains_key("$ref") {
@@ -299,9 +341,21 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
});
}
if let Some(constant) = schema.get("const") {
ensure_schema_keywords(schema, &["const", "type"], context)?;
if let Some(type_value) = schema.get("type") {
let kind = type_value.as_str().ok_or_else(|| {
GenerationError::invalid(format!("{context} const type must be a string"))
})?;
if !literal_matches_type(kind, constant) {
return Err(GenerationError::invalid(format!(
"{context} const literal does not match type `{kind}`"
)));
}
}
return render_literal(constant, context);
}
if let Some(enumeration) = schema.get("enum") {
ensure_schema_keywords(schema, &["enum", "type"], context)?;
let values = enumeration.as_array().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-array enum"))
})?;
@@ -310,6 +364,16 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
"{context} contains an empty enum"
)));
}
if let Some(type_value) = schema.get("type") {
let kind = type_value.as_str().ok_or_else(|| {
GenerationError::invalid(format!("{context} enum type must be a string"))
})?;
if !values.iter().all(|value| literal_matches_type(kind, value)) {
return Err(GenerationError::invalid(format!(
"{context} enum literals do not match type `{kind}`"
)));
}
}
return values
.iter()
.map(|value| render_literal(value, context))
@@ -317,9 +381,20 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
.map(|values| values.join(" | "));
}
if let Some(branches) = schema.get("anyOf") {
ensure_schema_keywords(schema, &["anyOf"], context)?;
return render_nullable_union(branches, context);
}
if let Some(branches) = schema.get("oneOf") {
ensure_schema_keywords(schema, &["oneOf", "type"], context)?;
let outer_type = match schema.get("type") {
Some(Value::String(kind)) if kind == "string" => Some(kind.as_str()),
Some(_) => {
return Err(GenerationError::invalid(format!(
"{context} contains an unsupported oneOf type"
)));
}
None => None,
};
let branches = branches.as_array().ok_or_else(|| {
GenerationError::invalid(format!("{context} contains a non-array oneOf"))
})?;
@@ -329,6 +404,7 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
)));
}
let mut rendered = Vec::with_capacity(branches.len());
let mut seen_literals = BTreeSet::new();
for branch in branches {
let branch_object = object(branch, context)?;
if !branch_object.contains_key("enum") && !branch_object.contains_key("const") {
@@ -336,6 +412,39 @@ fn render_schema(value: &Value, context: &str) -> Result<String, GenerationError
"{context} contains unsupported ambiguous oneOf semantics"
)));
}
let literals = if let Some(constant) = branch_object.get("const") {
std::slice::from_ref(constant)
} else {
branch_object
.get("enum")
.and_then(Value::as_array)
.ok_or_else(|| {
GenerationError::invalid(format!(
"{context} contains a non-array oneOf enum"
))
})?
.as_slice()
};
for literal in literals {
render_literal(literal, context)?;
let normalized = serde_json::to_string(literal).map_err(|error| {
GenerationError::invalid(format!(
"failed to normalize {context} oneOf literal: {error}"
))
})?;
if !seen_literals.insert(normalized) {
return Err(GenerationError::invalid(format!(
"{context} contains overlapping oneOf literal branches"
)));
}
}
if let Some(kind) = outer_type {
if !literal_schema_matches_type(branch_object, kind) {
return Err(GenerationError::invalid(format!(
"{context} oneOf branch does not match outer type `{kind}`"
)));
}
}
rendered.push(render_schema(branch, context)?);
}
return Ok(rendered.join(" | "));
@@ -384,6 +493,15 @@ fn render_nullable_union(value: &Value, context: &str) -> Result<String, Generat
"{context} contains unsupported ambiguous anyOf semantics"
)));
}
let null_branch = branches
.iter()
.find(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
.expect("validated one null branch");
if render_schema(null_branch, context)? != "null" {
return Err(GenerationError::invalid(format!(
"{context} contains an invalid nullable null branch"
)));
}
let branch = branches
.iter()
.find(|branch| branch.get("type").and_then(Value::as_str) != Some("null"))
@@ -397,28 +515,63 @@ fn render_typed_schema(
context: &str,
) -> Result<String, GenerationError> {
match kind {
"string" => Ok("string".to_owned()),
"boolean" => Ok("boolean".to_owned()),
"null" => Ok("null".to_owned()),
"string" => {
ensure_schema_keywords(schema, &["type"], context)?;
Ok("string".to_owned())
}
"boolean" => {
ensure_schema_keywords(schema, &["type"], context)?;
Ok("boolean".to_owned())
}
"null" => {
ensure_schema_keywords(schema, &["type"], context)?;
Ok("null".to_owned())
}
"integer" => {
ensure_schema_keywords(schema, &["format", "maximum", "minimum", "type"], context)?;
let format = match schema.get("format") {
Some(Value::String(format)) if format == "uint64" => Some(format.as_str()),
Some(Value::String(format)) => {
return Err(GenerationError::invalid(format!(
"{context} uses unsupported integer format `{format}`"
)));
}
Some(_) => {
return Err(GenerationError::invalid(format!(
"{context} integer format must be a string"
)));
}
None => None,
};
let minimum = schema.get("minimum").and_then(Value::as_f64);
let maximum = schema.get("maximum").and_then(Value::as_f64);
if minimum.is_none_or(|minimum| minimum < MIN_SAFE_INTEGER)
|| maximum.is_none_or(|maximum| maximum > MAX_SAFE_INTEGER)
{
let expected_minimum = if format == Some("uint64") {
0.0
} else {
MIN_SAFE_INTEGER
};
if minimum != Some(expected_minimum) || maximum != Some(MAX_SAFE_INTEGER) {
return Err(GenerationError::invalid(format!(
"{context} integer range exceeds JavaScript safe integers"
"{context} integer range does not match its JavaScript safe-number mapping"
)));
}
Ok("number".to_owned())
}
"array" => {
ensure_schema_keywords(schema, &["items", "type"], context)?;
let items = schema.get("items").ok_or_else(|| {
GenerationError::invalid(format!("{context} array has no item schema"))
})?;
Ok(format!("Array<{}>", render_schema(items, context)?))
}
"object" => render_object(schema, context),
"object" => {
ensure_schema_keywords(
schema,
&["additionalProperties", "properties", "required", "type"],
context,
)?;
render_object(schema, context)
}
other => Err(GenerationError::invalid(format!(
"{context} uses unsupported type `{other}`"
))),
@@ -565,7 +718,25 @@ mod tests {
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
serde_json::json!(9_007_199_254_740_992_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe integers"));
assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] =
serde_json::json!(-1);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] =
serde_json::json!(9_007_199_254_740_990_u64);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("safe-number"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] =
serde_json::json!("int64");
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("unsupported integer format"));
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
@@ -594,6 +765,78 @@ mod tests {
assert!(error.to_string().contains("unsupported `$ref` sibling"));
}
#[test]
fn generator_rejects_unimplemented_ordinary_schema_keywords() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["maxLength"] =
serde_json::json!(1);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(
error
.to_string()
.contains("unsupported schema keyword `maxLength`")
);
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositoryListResponse"]["properties"]["items"]["maxItems"] =
serde_json::json!(1);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(
error
.to_string()
.contains("unsupported schema keyword `maxItems`")
);
}
#[test]
fn generator_rejects_discarded_combined_schema_constraints() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["DiagnosticSeverity"]["const"] =
serde_json::json!("info");
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(
error
.to_string()
.contains("unsupported schema keyword `enum`")
);
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
let null_branch =
document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"]
.as_array_mut()
.unwrap()
.iter_mut()
.find(|branch| branch.get("type").and_then(Value::as_str) == Some("null"))
.unwrap();
null_branch["maxLength"] = serde_json::json!(1);
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(
error
.to_string()
.contains("unsupported schema keyword `maxLength`")
);
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySourceKind"]["type"] =
serde_json::json!("string");
document["components"]["schemas"]["RepositorySourceKind"]["oneOf"][0] =
serde_json::json!({"const": true, "type": "boolean"});
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(
error
.to_string()
.contains("oneOf branch does not match outer type")
);
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();
document["components"]["schemas"]["RepositorySourceKind"]["oneOf"]
.as_array_mut()
.unwrap()
.push(serde_json::json!({"const": "invalid", "type": "string"}));
let error = generate_repository_typescript(&document.to_string()).unwrap_err();
assert!(error.to_string().contains("overlapping oneOf literal"));
}
#[test]
fn generator_is_bounded_to_the_repository_operation_contract() {
let mut document: Value = serde_json::from_str(OPENAPI).unwrap();