1use serde_json::Value;
9use std::path::Path;
10
11use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
12use crate::document::html::{HtmlBlock, render_blocks_to_pages};
13use crate::error::{Error, Result};
14use crate::table::{TableAlign, TableData};
15
16const MAX_AVRO_BYTES: u64 = 64 * 1024 * 1024;
17const MAX_AVRO_DEPTH: usize = 100;
18const MAX_AVRO_VALUES: usize = 300_000;
19const MAX_AVRO_ROWS: usize = 200_000;
20const MAX_AVRO_STRING_BYTES: usize = 2 * 1024 * 1024;
21const MAX_AVRO_DISPLAY_BYTES: usize = 512;
22
23pub(crate) fn looks_like_prefix(prefix: &[u8]) -> bool {
24 let text = String::from_utf8_lossy(prefix);
25 let trimmed = text.trim_start_matches('\u{feff}').trim_start();
26 if !trimmed.starts_with('{') && !trimmed.starts_with('[') {
27 return false;
28 }
29 if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
30 return looks_like_value(&value);
31 }
32 text.contains("\"protocol\"") || (text.contains("\"fields\"") && text.contains("\"type\""))
33}
34
35fn looks_like_value(value: &Value) -> bool {
36 match value {
37 Value::Object(object) => {
38 (object.get("type").and_then(Value::as_str) == Some("record")
39 && object.get("fields").and_then(Value::as_array).is_some())
40 || (object.get("protocol").and_then(Value::as_str).is_some()
41 && object.get("messages").and_then(Value::as_object).is_some())
42 || object
43 .get("types")
44 .and_then(Value::as_array)
45 .is_some_and(|types| types.iter().any(looks_like_value))
46 }
47 Value::Array(values) => values.iter().any(looks_like_value),
48 _ => false,
49 }
50}
51
52struct AvroPageSink<'a> {
53 inner: &'a mut dyn PageConsumer,
54 warnings: &'a [String],
55}
56
57impl PageConsumer for AvroPageSink<'_> {
58 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
59 page.source_format = "avro".into();
60 if page.title.is_empty() {
61 page.title = "Apache Avro schema".into();
62 }
63 page.description =
64 "Avro schema and protocol metadata is rendered inertly; defaults, documentation, imports and message execution are not evaluated".into();
65 for warning in self.warnings {
66 page.warn(warning.clone());
67 }
68 self.inner.consume(page)
69 }
70}
71
72pub(crate) fn convert(
73 path: &Path,
74 options: &ConvertOptions,
75 sink: &mut dyn PageConsumer,
76) -> Result<Vec<String>> {
77 let bytes = read_limited_file(
78 path,
79 options.max_input_bytes.min(MAX_AVRO_BYTES),
80 "Avro JSON schema input",
81 )?;
82 let text = String::from_utf8(bytes)
83 .map_err(|error| Error::InvalidInput(format!("Avro JSON must be UTF-8 JSON: {error}")))?;
84 let (table, metadata, warnings) = parse(&text)?;
85 let blocks = vec![
86 HtmlBlock::Heading {
87 level: 1,
88 text: "Apache Avro schema".into(),
89 },
90 HtmlBlock::Paragraph { text: metadata },
91 HtmlBlock::Table(table),
92 ];
93 let mut page_sink = AvroPageSink {
94 inner: sink,
95 warnings: &warnings,
96 };
97 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
98 Ok(warnings)
99}
100
101fn parse(text: &str) -> Result<(TableData, String, Vec<String>)> {
102 if text.len() as u64 > MAX_AVRO_BYTES {
103 return Err(Error::LimitExceeded(format!(
104 "Avro JSON exceeds {MAX_AVRO_BYTES} bytes"
105 )));
106 }
107 preflight_depth(text)?;
108 let value: Value = serde_json::from_str(text)
109 .map_err(|error| Error::InvalidInput(format!("invalid Avro JSON: {error}")))?;
110 let mut values = 0usize;
111 count_values(&value, 0, &mut values)?;
112 let mut rows = Vec::new();
113 let mut warnings = Vec::new();
114 let mut named = 0usize;
115 let mut fields = 0usize;
116 let mut messages = 0usize;
117 let metadata = match &value {
118 Value::Object(object) if object.get("protocol").is_some() => {
119 let protocol = required_string(object, "protocol")?;
120 let namespace = object
121 .get("namespace")
122 .and_then(Value::as_str)
123 .unwrap_or("—");
124 let types = object
125 .get("types")
126 .and_then(Value::as_array)
127 .map_or(0, Vec::len);
128 if let Some(types) = object.get("types") {
129 let types = types.as_array().ok_or_else(|| {
130 Error::InvalidInput("Avro protocol types must be an array".into())
131 })?;
132 for schema in types {
133 walk_schema(schema, "/types", 0, &mut rows, &mut named, &mut fields)?;
134 }
135 }
136 if let Some(message_map) = object.get("messages") {
137 let message_map = message_map.as_object().ok_or_else(|| {
138 Error::InvalidInput("Avro protocol messages must be an object".into())
139 })?;
140 for (name, message) in message_map {
141 messages = messages.saturating_add(1);
142 let message = message.as_object().ok_or_else(|| {
143 Error::InvalidInput(format!("Avro message {name} must be an object"))
144 })?;
145 let request = message
146 .get("request")
147 .and_then(Value::as_array)
148 .map_or(0, Vec::len);
149 let response = type_label(message.get("response"));
150 let errors = message
151 .get("errors")
152 .and_then(Value::as_array)
153 .map_or(0, Vec::len);
154 push_row(
155 &mut rows,
156 vec![
157 format!("/messages/{name}"),
158 "message".into(),
159 format!("request {request}; response {response}; errors {errors}"),
160 ],
161 )?;
162 }
163 }
164 let metadata = format!(
165 "Protocol: {}\nNamespace: {}\nNamed schemas: {}\nFields: {}\nMessages: {}\nDeclared types: {}",
166 truncate(protocol),
167 truncate(namespace),
168 named,
169 fields,
170 messages,
171 types
172 );
173 metadata
174 }
175 Value::Object(object) => {
176 walk_schema(&value, "/", 0, &mut rows, &mut named, &mut fields)?;
177 let name = object
178 .get("name")
179 .and_then(Value::as_str)
180 .unwrap_or("(anonymous)");
181 format!(
182 "Root schema: {}\nNamed schemas: {}\nFields: {}",
183 truncate(name),
184 named,
185 fields
186 )
187 }
188 Value::Array(values) => {
189 for (index, schema) in values.iter().enumerate() {
190 walk_schema(
191 schema,
192 &format!("/{index}"),
193 0,
194 &mut rows,
195 &mut named,
196 &mut fields,
197 )?;
198 }
199 format!(
200 "Root union members: {}\nNamed schemas: {}\nFields: {}",
201 values.len(),
202 named,
203 fields
204 )
205 }
206 _ => {
207 return Err(Error::InvalidInput(
208 "Avro JSON root must be a schema object, protocol or union array".into(),
209 ));
210 }
211 };
212 if rows.is_empty() {
213 rows.push(vec!["/".into(), "schema".into(), "(no fields)".into()]);
214 }
215 warnings.push("Avro defaults, docs, aliases, logical-type semantics, field values, imports and protocol messages remain inert; no code generation, deserialization, RPC or external resource access occurs".into());
216 warnings.push("Avro JSON is bounded by input size, nesting depth, value count, schema-row count and string length; named references are shown as type text and are not resolved".into());
217 Ok((
218 TableData {
219 headers: vec!["Path".into(), "Kind".into(), "Type / detail".into()],
220 rows,
221 alignments: vec![TableAlign::Left; 3],
222 raw_source: String::new(),
223 },
224 metadata,
225 warnings,
226 ))
227}
228
229fn walk_schema(
230 value: &Value,
231 path: &str,
232 depth: usize,
233 rows: &mut Vec<Vec<String>>,
234 named: &mut usize,
235 fields: &mut usize,
236) -> Result<()> {
237 if depth > MAX_AVRO_DEPTH {
238 return Err(Error::LimitExceeded(format!(
239 "Avro schema nesting exceeds {MAX_AVRO_DEPTH} levels"
240 )));
241 }
242 match value {
243 Value::String(type_name) => {
244 push_row(
245 rows,
246 vec![truncate(path), "reference".into(), truncate(type_name)],
247 )?;
248 }
249 Value::Array(members) => {
250 push_row(
251 rows,
252 vec![
253 truncate(path),
254 "union".into(),
255 format!("{} members", members.len()),
256 ],
257 )?;
258 for (index, member) in members.iter().enumerate() {
259 walk_schema(
260 member,
261 &format!("{path}/{index}"),
262 depth + 1,
263 rows,
264 named,
265 fields,
266 )?;
267 }
268 }
269 Value::Object(object) => {
270 let type_value = object
271 .get("type")
272 .ok_or_else(|| Error::InvalidInput(format!("Avro schema {path} requires type")))?;
273 let kind = type_label(Some(type_value));
274 match type_value {
275 Value::Object(_) | Value::Array(_) => {
276 walk_schema(
277 type_value,
278 &format!("{path}/type"),
279 depth + 1,
280 rows,
281 named,
282 fields,
283 )?;
284 }
285 _ => {}
286 }
287 match kind.as_str() {
288 "record" | "error" | "request" => {
289 let name = required_string(object, "name")?;
290 *named = named.saturating_add(1);
291 push_row(
292 rows,
293 vec![truncate(path), kind.clone(), format!("name {name}")],
294 )?;
295 let field_values =
296 object
297 .get("fields")
298 .and_then(Value::as_array)
299 .ok_or_else(|| {
300 Error::InvalidInput(format!(
301 "Avro {kind} {name} requires fields array"
302 ))
303 })?;
304 for field in field_values {
305 let field_object = field.as_object().ok_or_else(|| {
306 Error::InvalidInput(format!("Avro field in {name} must be an object"))
307 })?;
308 let field_name = required_string(field_object, "name")?;
309 let field_type = field_object.get("type").ok_or_else(|| {
310 Error::InvalidInput(format!("Avro field {field_name} requires type"))
311 })?;
312 *fields = fields.saturating_add(1);
313 let field_path =
314 format!("{}/fields/{field_name}", path.trim_end_matches('/'));
315 push_row(
316 rows,
317 vec![
318 truncate(&field_path),
319 "field".into(),
320 type_label(Some(field_type)),
321 ],
322 )?;
323 walk_schema(
324 field_type,
325 &format!("{field_path}/type"),
326 depth + 1,
327 rows,
328 named,
329 fields,
330 )?;
331 }
332 }
333 "enum" => {
334 let name = required_string(object, "name")?;
335 let symbols =
336 object
337 .get("symbols")
338 .and_then(Value::as_array)
339 .ok_or_else(|| {
340 Error::InvalidInput(format!(
341 "Avro enum {name} requires symbols array"
342 ))
343 })?;
344 *named = named.saturating_add(1);
345 push_row(
346 rows,
347 vec![
348 truncate(path),
349 "enum".into(),
350 format!("name {name}; {} symbols", symbols.len()),
351 ],
352 )?;
353 }
354 "fixed" => {
355 let name = required_string(object, "name")?;
356 let size = object.get("size").and_then(Value::as_u64).ok_or_else(|| {
357 Error::InvalidInput(format!("Avro fixed {name} requires unsigned size"))
358 })?;
359 *named = named.saturating_add(1);
360 push_row(
361 rows,
362 vec![
363 truncate(path),
364 "fixed".into(),
365 format!("name {name}; {size} bytes"),
366 ],
367 )?;
368 }
369 "array" => {
370 let items = object.get("items").ok_or_else(|| {
371 Error::InvalidInput(format!("Avro array {path} requires items"))
372 })?;
373 push_row(
374 rows,
375 vec![truncate(path), "array".into(), type_label(Some(items))],
376 )?;
377 walk_schema(
378 items,
379 &format!("{path}/items"),
380 depth + 1,
381 rows,
382 named,
383 fields,
384 )?;
385 }
386 "map" => {
387 let values = object.get("values").ok_or_else(|| {
388 Error::InvalidInput(format!("Avro map {path} requires values"))
389 })?;
390 push_row(
391 rows,
392 vec![truncate(path), "map".into(), type_label(Some(values))],
393 )?;
394 walk_schema(
395 values,
396 &format!("{path}/values"),
397 depth + 1,
398 rows,
399 named,
400 fields,
401 )?;
402 }
403 _ => {
404 push_row(rows, vec![truncate(path), "schema".into(), kind])?;
405 }
406 }
407 }
408 _ => {
409 return Err(Error::InvalidInput(format!(
410 "Avro schema {path} must be a string, object or union array"
411 )));
412 }
413 }
414 Ok(())
415}
416
417fn push_row(rows: &mut Vec<Vec<String>>, row: Vec<String>) -> Result<()> {
418 if rows.len() >= MAX_AVRO_ROWS {
419 return Err(Error::LimitExceeded(format!(
420 "Avro schema rows exceed {MAX_AVRO_ROWS}"
421 )));
422 }
423 rows.push(row);
424 Ok(())
425}
426
427fn required_string<'a>(object: &'a serde_json::Map<String, Value>, key: &str) -> Result<&'a str> {
428 let value = object
429 .get(key)
430 .and_then(Value::as_str)
431 .ok_or_else(|| Error::InvalidInput(format!("Avro schema requires string {key}")))?;
432 if value.is_empty() {
433 return Err(Error::InvalidInput(format!(
434 "Avro schema {key} must not be empty"
435 )));
436 }
437 Ok(value)
438}
439
440fn type_label(value: Option<&Value>) -> String {
441 match value {
442 Some(Value::String(value)) => truncate(value),
443 Some(Value::Array(values)) => format!("union({})", values.len()),
444 Some(Value::Object(object)) => object
445 .get("type")
446 .and_then(Value::as_str)
447 .map_or_else(|| "object".into(), truncate),
448 Some(_) => "invalid".into(),
449 None => "—".into(),
450 }
451}
452
453fn truncate(value: &str) -> String {
454 if value.len() <= MAX_AVRO_DISPLAY_BYTES {
455 return value.to_owned();
456 }
457 let mut end = MAX_AVRO_DISPLAY_BYTES;
458 while !value.is_char_boundary(end) {
459 end -= 1;
460 }
461 format!("{}…", &value[..end])
462}
463
464fn count_values(value: &Value, depth: usize, count: &mut usize) -> Result<()> {
465 if depth > MAX_AVRO_DEPTH {
466 return Err(Error::LimitExceeded(format!(
467 "Avro JSON nesting exceeds {MAX_AVRO_DEPTH} levels"
468 )));
469 }
470 *count = count.saturating_add(1);
471 if *count > MAX_AVRO_VALUES {
472 return Err(Error::LimitExceeded(format!(
473 "Avro JSON contains more than {MAX_AVRO_VALUES} values"
474 )));
475 }
476 match value {
477 Value::Array(values) => {
478 for item in values {
479 count_values(item, depth + 1, count)?;
480 }
481 }
482 Value::Object(map) => {
483 for item in map.values() {
484 count_values(item, depth + 1, count)?;
485 }
486 }
487 Value::String(value) if value.len() > MAX_AVRO_STRING_BYTES => {
488 return Err(Error::LimitExceeded(format!(
489 "Avro JSON string exceeds {MAX_AVRO_STRING_BYTES} bytes"
490 )));
491 }
492 _ => {}
493 }
494 Ok(())
495}
496
497fn preflight_depth(text: &str) -> Result<()> {
498 let mut depth = 0usize;
499 let mut quoted = false;
500 let mut escaped = false;
501 for byte in text.bytes() {
502 if quoted {
503 if escaped {
504 escaped = false;
505 } else if byte == b'\\' {
506 escaped = true;
507 } else if byte == b'"' {
508 quoted = false;
509 }
510 continue;
511 }
512 match byte {
513 b'"' => quoted = true,
514 b'{' | b'[' => {
515 depth += 1;
516 if depth > MAX_AVRO_DEPTH {
517 return Err(Error::LimitExceeded(format!(
518 "Avro JSON nesting exceeds {MAX_AVRO_DEPTH} levels"
519 )));
520 }
521 }
522 b'}' | b']' => depth = depth.saturating_sub(1),
523 _ => {}
524 }
525 }
526 Ok(())
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532
533 #[test]
534 fn recognizes_avro_schema_and_protocol() {
535 assert!(looks_like_prefix(
536 br#"{"type":"record","name":"User","fields":[{"name":"id","type":"long"}]}"#
537 ));
538 assert!(looks_like_prefix(br#"{"protocol":"Ping","messages":{}}"#));
539 assert!(!looks_like_prefix(br#"{"type":"object","properties":{}}"#));
540 }
541
542 #[test]
543 fn summarizes_nested_schema_without_defaults_or_docs() {
544 let (table, metadata, warnings) = parse(
545 r#"{"type":"record","name":"User","namespace":"example","doc":"secret docs","fields":[{"name":"id","type":"long","default":7},{"name":"profile","type":{"type":"record","name":"Profile","fields":[{"name":"email","type":"string"}]}},{"name":"tags","type":{"type":"array","items":"string"}}]}"#,
546 )
547 .unwrap();
548 assert!(metadata.contains("Root schema: User"));
549 assert!(table.rows.iter().any(|row| row[0].contains("profile")));
550 assert!(
551 !table
552 .rows
553 .iter()
554 .flatten()
555 .any(|value| value.contains("secret") || value.contains("7"))
556 );
557 assert!(warnings.iter().any(|warning| warning.contains("defaults")));
558 }
559}