use std::collections::HashMap;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Severity {
Allow,
Warn,
Strip,
Forbid,
Unknown,
}
impl Severity {
pub fn parse(s: &str) -> Result<Self, ProfileError> {
match s {
"allow" => Ok(Severity::Allow),
"warn" => Ok(Severity::Warn),
"strip" => Ok(Severity::Strip),
"forbid" => Ok(Severity::Forbid),
"unknown" => Ok(Severity::Unknown),
other => Err(ProfileError::InvalidSeverity(other.to_string())),
}
}
}
#[derive(Debug, Clone)]
pub struct Profile {
pub name: String,
pub version: String,
pub code_prefix: String,
pub keyword_map: HashMap<&'static str, Severity>,
pub restrictions: HashMap<&'static str, Restriction>,
pub structural: StructuralLimits,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Restriction {
pub allowed_values: Vec<Value>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
#[serde(default)]
pub struct StructuralLimits {
pub require_object_root: bool,
pub require_additional_properties_false: bool,
pub require_all_properties_in_required: bool,
pub require_array_items: bool,
pub forbid_root_any_of: bool,
pub forbid_root_enum: bool,
pub forbid_empty_object: bool,
pub max_object_depth: u32,
pub max_total_properties: u32,
pub max_total_enum_values: u32,
pub max_string_length_total: u32,
pub max_optional_properties: u32,
pub max_union_properties: u32,
pub external_refs: bool,
pub forbid_allof_with_ref: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
#[error("invalid TOML: {0}")]
InvalidToml(#[from] toml::de::Error),
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("invalid severity '{0}'; expected one of: allow, warn, strip, forbid, unknown")]
InvalidSeverity(String),
#[error("invalid restriction for keyword '{0}': missing 'allowed' array")]
InvalidRestriction(String),
}
pub fn load(bytes: &[u8]) -> Result<Profile, ProfileError> {
let text = std::str::from_utf8(bytes)
.map_err(|_| ProfileError::InvalidSeverity("invalid UTF-8 in profile".to_string()))?;
let doc: toml::Value = text.parse()?;
let table = doc
.as_table()
.ok_or(ProfileError::MissingField("root table"))?;
let name = table
.get("name")
.and_then(|v| v.as_str())
.ok_or(ProfileError::MissingField("name"))?
.to_string();
let version = table
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let code_prefix = table
.get("code_prefix")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| {
let first_segment = name.split('.').next().unwrap_or(&name);
first_segment.to_uppercase()
});
let mut keyword_map = HashMap::new();
let mut restrictions = HashMap::new();
const KNOWN_KEYWORDS: &[&str] = &[
"type",
"properties",
"required",
"additionalProperties",
"items",
"prefixItems",
"minItems",
"maxItems",
"uniqueItems",
"contains",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"multipleOf",
"minLength",
"maxLength",
"pattern",
"format",
"enum",
"const",
"patternProperties",
"unevaluatedProperties",
"propertyNames",
"minProperties",
"maxProperties",
"description",
"title",
"default",
"discriminator",
"$ref",
"$defs",
"definitions",
"anyOf",
"allOf",
"oneOf",
"not",
"if",
"then",
"else",
"dependentRequired",
"dependentSchemas",
];
for (key, val) in table {
match key.as_str() {
"name" | "version" | "code_prefix" | "structural" | "restrictions" => continue,
_ => {}
}
if !KNOWN_KEYWORDS.contains(&key.as_str()) {
return Err(ProfileError::InvalidSeverity(format!(
"unknown keyword '{}' in profile; expected a known JSON Schema keyword",
key
)));
}
match val {
toml::Value::String(s) => {
let sev = Severity::parse(s)?;
keyword_map.insert(leak_str(key), sev);
}
toml::Value::Table(t)
if t.get("kind").and_then(|v| v.as_str()) == Some("restricted") =>
{
let allowed = t
.get("allowed")
.and_then(|v| v.as_array())
.ok_or_else(|| ProfileError::InvalidRestriction(key.clone()))?;
let mut values = Vec::new();
for v in allowed {
values.push(toml_to_json(v.clone())?);
}
restrictions.insert(
leak_str(key),
Restriction {
allowed_values: values,
},
);
}
_ => {
return Err(ProfileError::InvalidSeverity(format!(
"invalid value for keyword '{}': expected string severity or restricted table",
key
)));
}
}
}
if let Some(toml::Value::Array(arr)) = table.get("restrictions") {
for entry in arr {
let t = entry
.as_table()
.ok_or(ProfileError::MissingField("restrictions entry"))?;
let keyword = t
.get("keyword")
.and_then(|v| v.as_str())
.ok_or(ProfileError::MissingField("restrictions.keyword"))?;
let allowed = t
.get("allowed")
.and_then(|v| v.as_array())
.ok_or_else(|| ProfileError::InvalidRestriction(keyword.to_string()))?;
let mut values = Vec::new();
for v in allowed {
values.push(toml_to_json(v.clone())?);
}
restrictions.insert(
leak_str(keyword),
Restriction {
allowed_values: values,
},
);
}
}
let structural = parse_structural(table.get("structural"))?;
Ok(Profile {
name,
version,
code_prefix,
keyword_map,
restrictions,
structural,
})
}
fn parse_structural(val: Option<&toml::Value>) -> Result<StructuralLimits, ProfileError> {
let Some(v @ toml::Value::Table(_)) = val else {
return Err(ProfileError::MissingField("[structural] section"));
};
Ok(v.clone().try_into()?)
}
fn leak_str(s: &str) -> &'static str {
Box::leak(s.to_owned().into_boxed_str())
}
fn toml_to_json(val: toml::Value) -> Result<Value, ProfileError> {
match val {
toml::Value::String(s) => Ok(Value::String(s)),
toml::Value::Integer(i) => Ok(Value::Number(serde_json::Number::from(i))),
toml::Value::Float(f) => {
let num = serde_json::Number::from_f64(f).ok_or_else(|| {
ProfileError::InvalidSeverity(format!("invalid float value: {f}"))
})?;
Ok(Value::Number(num))
}
toml::Value::Boolean(b) => Ok(Value::Bool(b)),
toml::Value::Array(arr) => {
let mut out = Vec::new();
for v in arr {
out.push(toml_to_json(v)?);
}
Ok(Value::Array(out))
}
toml::Value::Table(map) => {
let mut out = serde_json::Map::new();
for (k, v) in map {
out.insert(k, toml_to_json(v)?);
}
Ok(Value::Object(out))
}
toml::Value::Datetime(dt) => Ok(Value::String(dt.to_string())),
}
}