use std::sync::OnceLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::Error;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AllowList {
pub pointer: String,
pub values: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputPolicy {
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<Value>,
pub allow: Vec<AllowList>,
pub forbid_html: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_bytes: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ViolationKind {
TooLong,
NotJson,
Schema,
NotAllowed,
Html,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Violation {
pub kind: ViolationKind,
pub path: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OutputReport {
pub violations: Vec<Violation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub json: Option<Value>,
}
impl OutputReport {
pub fn is_valid(&self) -> bool {
self.violations.is_empty()
}
}
pub struct OutputValidator {
policy: OutputPolicy,
schema: Option<jsonschema::Validator>,
}
impl std::fmt::Debug for OutputValidator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OutputValidator")
.field("policy", &self.policy)
.finish_non_exhaustive()
}
}
impl OutputValidator {
pub fn new(policy: OutputPolicy) -> Result<Self, Error> {
for a in &policy.allow {
if !a.pointer.starts_with('/') {
return Err(Error::InvalidPointer(a.pointer.clone()));
}
}
let schema = match &policy.schema {
Some(s) => Some(
jsonschema::validator_for(s).map_err(|e| Error::InvalidSchema(e.to_string()))?,
),
None => None,
};
Ok(Self { policy, schema })
}
pub fn policy(&self) -> &OutputPolicy {
&self.policy
}
pub fn validate(&self, text: &str) -> OutputReport {
let mut violations = Vec::new();
if let Some(max) = self.policy.max_bytes.filter(|&max| text.len() > max) {
violations.push(Violation {
kind: ViolationKind::TooLong,
path: String::new(),
message: format!("output is {} bytes, limit is {max}", text.len()),
});
}
let needs_json = self.schema.is_some() || !self.policy.allow.is_empty();
let json: Option<Value> = serde_json::from_str(text).ok();
match &json {
None if needs_json => violations.push(Violation {
kind: ViolationKind::NotJson,
path: String::new(),
message: "output is not valid JSON".to_owned(),
}),
None => {
if self.policy.forbid_html && contains_html(text) {
violations.push(Violation {
kind: ViolationKind::Html,
path: String::new(),
message: "output contains HTML".to_owned(),
});
}
}
Some(v) => {
if let Some(schema) = &self.schema {
for err in schema.iter_errors(v) {
violations.push(Violation {
kind: ViolationKind::Schema,
path: err.instance_path().to_string(),
message: schema_message(&err),
});
}
}
for a in &self.policy.allow {
let mut hits = Vec::new();
select(v, &a.pointer[1..], String::new(), &mut hits);
for (path, val) in hits {
let s = match val {
Value::String(s) => s.clone(),
other => other.to_string(),
};
if !a.values.contains(&s) {
violations.push(Violation {
kind: ViolationKind::NotAllowed,
path,
message: format!(
"value is not in the allow-list for {}",
a.pointer
),
});
}
}
}
if self.policy.forbid_html {
let mut hits = Vec::new();
strings(v, String::new(), &mut hits);
for (path, s) in hits {
if contains_html(s) {
violations.push(Violation {
kind: ViolationKind::Html,
path,
message: "string value contains HTML".to_owned(),
});
}
}
}
}
}
OutputReport { violations, json }
}
}
fn schema_message(err: &jsonschema::ValidationError<'_>) -> String {
let keyword = err.kind();
format!("schema violation at `{}`: {keyword:?}", err.schema_path())
}
fn select<'a>(v: &'a Value, pointer: &str, path: String, out: &mut Vec<(String, &'a Value)>) {
if pointer.is_empty() {
out.push((path, v));
return;
}
let (head, rest) = pointer.split_once('/').unwrap_or((pointer, ""));
let head = head.replace("~1", "/").replace("~0", "~");
match v {
Value::Array(items) if head == "*" => {
for (i, item) in items.iter().enumerate() {
select(item, rest, format!("{path}/{i}"), out);
}
}
Value::Array(items) => {
if let Some((i, item)) = head
.parse::<usize>()
.ok()
.and_then(|i| Some((i, items.get(i)?)))
{
select(item, rest, format!("{path}/{i}"), out);
}
}
Value::Object(map) if head == "*" => {
for (k, item) in map {
select(item, rest, format!("{path}/{}", escape(k)), out);
}
}
Value::Object(map) => {
if let Some(item) = map.get(&head) {
select(item, rest, format!("{path}/{}", escape(&head)), out);
}
}
_ => {}
}
}
fn escape(segment: &str) -> String {
segment.replace('~', "~0").replace('/', "~1")
}
fn strings<'a>(v: &'a Value, path: String, out: &mut Vec<(String, &'a str)>) {
match v {
Value::String(s) => out.push((path, s)),
Value::Array(items) => {
for (i, item) in items.iter().enumerate() {
strings(item, format!("{path}/{i}"), out);
}
}
Value::Object(map) => {
for (k, item) in map {
strings(item, format!("{path}/{}", escape(k)), out);
}
}
_ => {}
}
}
fn html_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"(?is)</?[a-z][a-z0-9-]*(?:\s[^<>]*)?/?>|<!--|<!\[cdata\[")
.expect("valid regex")
})
}
pub fn contains_html(text: &str) -> bool {
html_regex().is_match(text)
}
pub fn strip_html(text: &str) -> String {
html_regex().replace_all(text, "").into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn detects_tags_comments_and_cdata() {
assert!(contains_html("hi <b>there</b>"));
assert!(contains_html("<SCRIPT src=x>"));
assert!(contains_html("<!-- hidden -->"));
assert!(contains_html("<![CDATA[x]]>"));
assert!(!contains_html("a < b and c > d"));
assert!(!contains_html("1 <2> 3")); }
#[test]
fn strip_removes_tags_only() {
assert_eq!(strip_html("<p>Hello <b>world</b></p>"), "Hello world");
}
#[test]
fn pointer_wildcards_select_array_elements() {
let v = json!({"steps": [{"action": "read"}, {"action": "delete"}]});
let mut out = Vec::new();
select(&v, "steps/*/action", String::new(), &mut out);
let paths: Vec<_> = out.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(paths, ["/steps/0/action", "/steps/1/action"]);
}
#[test]
fn pointer_escapes_are_decoded() {
let v = json!({"a/b": {"~": 1}});
let mut out = Vec::new();
select(&v, "a~1b/~0", String::new(), &mut out);
assert_eq!(out.len(), 1);
assert_eq!(out[0].0, "/a~1b/~0");
}
#[test]
fn rejects_bad_pointer() {
let err = OutputValidator::new(OutputPolicy {
allow: vec![AllowList {
pointer: "steps".into(),
values: vec![],
}],
..Default::default()
})
.unwrap_err();
assert!(matches!(err, Error::InvalidPointer(_)));
}
}