#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use serde_json::Value;
use super::ast::Program;
use super::error::{ConstelaError, Diagnostic, codes};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
pub max_bytes: usize,
pub max_depth: usize,
pub max_nodes: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_bytes: 512 * 1024,
max_depth: 64,
max_nodes: 20_000,
}
}
}
impl Limits {
#[must_use]
pub const fn unbounded() -> Self {
Self {
max_bytes: usize::MAX,
max_depth: usize::MAX,
max_nodes: usize::MAX,
}
}
}
pub fn parse_program(source: &str, limits: &Limits) -> Result<Program, ConstelaError> {
if source.len() > limits.max_bytes {
return Err(ConstelaError::Limit(Diagnostic::new(
"",
codes::LIMIT,
format!(
"document is {} bytes, over the {}-byte limit",
source.len(),
limits.max_bytes
),
)));
}
let value: Value = serde_json::from_str(source).map_err(|err| {
ConstelaError::Syntax(Diagnostic::new(
"",
codes::SYNTAX,
format!("not valid JSON: {err}"),
))
})?;
check_shape(&value, limits)?;
Program::deserialize_value(value)
}
impl Program {
fn deserialize_value(value: Value) -> Result<Self, ConstelaError> {
serde_json::from_value(value).map_err(|err| {
ConstelaError::Syntax(Diagnostic::new(
"",
codes::SYNTAX,
format!("does not match the Constela AST: {err}"),
))
})
}
}
fn check_shape(value: &Value, limits: &Limits) -> Result<(), ConstelaError> {
let mut nodes = 0usize;
let mut stack: Vec<(&Value, usize)> = vec![(value, 1)];
while let Some((current, depth)) = stack.pop() {
nodes = nodes.saturating_add(1);
if nodes > limits.max_nodes {
return Err(ConstelaError::Limit(Diagnostic::new(
"",
codes::LIMIT,
format!("document has more than {} JSON nodes", limits.max_nodes),
)));
}
if depth > limits.max_depth {
return Err(ConstelaError::Limit(Diagnostic::new(
"",
codes::LIMIT,
format!("document nests deeper than {} levels", limits.max_depth),
)));
}
match current {
Value::Array(items) => {
let next = depth.saturating_add(1);
stack.extend(items.iter().map(|item| (item, next)));
}
Value::Object(entries) => {
let next = depth.saturating_add(1);
stack.extend(entries.iter().map(|(_, item)| (item, next)));
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn tiny_view() -> &'static str {
r#"{"version":"1.0","view":{"kind":"text","value":{"expr":"lit","value":"hi"}}}"#
}
#[test]
fn parses_a_minimal_program() {
let program = parse_program(tiny_view(), &Limits::default()).expect("parses");
assert_eq!(program.version, "1.0");
assert_eq!(program.view.kind(), "text");
}
#[test]
fn rejects_oversized_source_without_parsing_it() {
let limits = Limits {
max_bytes: 16,
..Limits::default()
};
let err = parse_program(tiny_view(), &limits).expect_err("over the byte limit");
assert!(matches!(err, ConstelaError::Limit(_)));
assert_eq!(err.diagnostics()[0].code, codes::LIMIT);
}
#[test]
fn rejects_deep_nesting_before_typed_deserialization() {
let mut deep = String::from(r#"{"version":"1.0","view":{"kind":"text","value":"#);
let depth = 100;
for _ in 0..depth {
deep.push_str(r#"{"expr":"not","operand":"#);
}
deep.push_str(r#"{"expr":"lit","value":true}"#);
for _ in 0..depth {
deep.push('}');
}
deep.push_str("}}");
let err = parse_program(&deep, &Limits::default()).expect_err("over the depth limit");
assert!(matches!(err, ConstelaError::Limit(_)));
assert!(err.diagnostics()[0].message.contains("nests deeper"));
}
#[test]
fn rejects_too_many_nodes() {
let items: Vec<String> = (0..500)
.map(|i| format!(r#"{{"kind":"text","value":{{"expr":"lit","value":{i}}}}}"#))
.collect();
let source = format!(
r#"{{"version":"1.0","view":{{"kind":"element","tag":"div","children":[{}]}}}}"#,
items.join(",")
);
let limits = Limits {
max_nodes: 100,
..Limits::default()
};
let err = parse_program(&source, &limits).expect_err("over the node limit");
assert!(err.diagnostics()[0].message.contains("JSON nodes"));
}
#[test]
fn reports_bad_json_as_syntax() {
let err = parse_program("{not json", &Limits::default()).expect_err("bad json");
assert!(matches!(err, ConstelaError::Syntax(_)));
assert_eq!(err.diagnostics()[0].code, codes::SYNTAX);
}
#[test]
fn unknown_expression_variant_names_the_supported_ones() {
let source =
r#"{"version":"1.0","view":{"kind":"text","value":{"expr":"call","method":"x"}}}"#;
let err = parse_program(source, &Limits::default()).expect_err("call is unsupported");
let message = &err.diagnostics()[0].message;
assert!(message.contains("call"), "{message}");
assert!(message.contains("lit"), "{message}");
}
#[test]
fn unknown_fields_are_ignored() {
let source = r#"{"version":"1.0","theme":{"mode":"dark"},
"view":{"kind":"element","tag":"div","transition":{"enter":"x"},"children":[]}}"#;
let program =
parse_program(source, &Limits::default()).expect("unknown fields are ignored");
assert_eq!(program.view.kind(), "element");
}
}