1use std::sync::OnceLock;
10
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::Error;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct AllowList {
24 pub pointer: String,
26 pub values: Vec<String>,
28}
29
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(default)]
33pub struct OutputPolicy {
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub schema: Option<Value>,
37 pub allow: Vec<AllowList>,
39 pub forbid_html: bool,
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub max_bytes: Option<usize>,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum ViolationKind {
51 TooLong,
53 NotJson,
55 Schema,
57 NotAllowed,
59 Html,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct Violation {
66 pub kind: ViolationKind,
68 pub path: String,
70 pub message: String,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct OutputReport {
77 pub violations: Vec<Violation>,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub json: Option<Value>,
82}
83
84impl OutputReport {
85 pub fn is_valid(&self) -> bool {
87 self.violations.is_empty()
88 }
89}
90
91pub struct OutputValidator {
93 policy: OutputPolicy,
94 schema: Option<jsonschema::Validator>,
95}
96
97impl std::fmt::Debug for OutputValidator {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 f.debug_struct("OutputValidator")
100 .field("policy", &self.policy)
101 .finish_non_exhaustive()
102 }
103}
104
105impl OutputValidator {
106 pub fn new(policy: OutputPolicy) -> Result<Self, Error> {
108 for a in &policy.allow {
109 if !a.pointer.starts_with('/') {
110 return Err(Error::InvalidPointer(a.pointer.clone()));
111 }
112 }
113 let schema = match &policy.schema {
114 Some(s) => Some(
115 jsonschema::validator_for(s).map_err(|e| Error::InvalidSchema(e.to_string()))?,
116 ),
117 None => None,
118 };
119 Ok(Self { policy, schema })
120 }
121
122 pub fn policy(&self) -> &OutputPolicy {
124 &self.policy
125 }
126
127 pub fn validate(&self, text: &str) -> OutputReport {
129 let mut violations = Vec::new();
130
131 if let Some(max) = self.policy.max_bytes.filter(|&max| text.len() > max) {
132 violations.push(Violation {
133 kind: ViolationKind::TooLong,
134 path: String::new(),
135 message: format!("output is {} bytes, limit is {max}", text.len()),
136 });
137 }
138
139 let needs_json = self.schema.is_some() || !self.policy.allow.is_empty();
140 let json: Option<Value> = serde_json::from_str(text).ok();
141
142 match &json {
143 None if needs_json => violations.push(Violation {
144 kind: ViolationKind::NotJson,
145 path: String::new(),
146 message: "output is not valid JSON".to_owned(),
147 }),
148 None => {
149 if self.policy.forbid_html && contains_html(text) {
150 violations.push(Violation {
151 kind: ViolationKind::Html,
152 path: String::new(),
153 message: "output contains HTML".to_owned(),
154 });
155 }
156 }
157 Some(v) => {
158 if let Some(schema) = &self.schema {
159 for err in schema.iter_errors(v) {
160 violations.push(Violation {
161 kind: ViolationKind::Schema,
162 path: err.instance_path().to_string(),
163 message: schema_message(&err),
164 });
165 }
166 }
167 for a in &self.policy.allow {
168 let mut hits = Vec::new();
169 select(v, &a.pointer[1..], String::new(), &mut hits);
170 for (path, val) in hits {
171 let s = match val {
172 Value::String(s) => s.clone(),
173 other => other.to_string(),
174 };
175 if !a.values.contains(&s) {
176 violations.push(Violation {
177 kind: ViolationKind::NotAllowed,
178 path,
179 message: format!(
180 "value is not in the allow-list for {}",
181 a.pointer
182 ),
183 });
184 }
185 }
186 }
187 if self.policy.forbid_html {
188 let mut hits = Vec::new();
189 strings(v, String::new(), &mut hits);
190 for (path, s) in hits {
191 if contains_html(s) {
192 violations.push(Violation {
193 kind: ViolationKind::Html,
194 path,
195 message: "string value contains HTML".to_owned(),
196 });
197 }
198 }
199 }
200 }
201 }
202
203 OutputReport { violations, json }
204 }
205}
206
207fn schema_message(err: &jsonschema::ValidationError<'_>) -> String {
209 let keyword = err.kind();
210 format!("schema violation at `{}`: {keyword:?}", err.schema_path())
211}
212
213fn select<'a>(v: &'a Value, pointer: &str, path: String, out: &mut Vec<(String, &'a Value)>) {
215 if pointer.is_empty() {
216 out.push((path, v));
217 return;
218 }
219 let (head, rest) = pointer.split_once('/').unwrap_or((pointer, ""));
220 let head = head.replace("~1", "/").replace("~0", "~");
221 match v {
222 Value::Array(items) if head == "*" => {
223 for (i, item) in items.iter().enumerate() {
224 select(item, rest, format!("{path}/{i}"), out);
225 }
226 }
227 Value::Array(items) => {
228 if let Some((i, item)) = head
229 .parse::<usize>()
230 .ok()
231 .and_then(|i| Some((i, items.get(i)?)))
232 {
233 select(item, rest, format!("{path}/{i}"), out);
234 }
235 }
236 Value::Object(map) if head == "*" => {
237 for (k, item) in map {
238 select(item, rest, format!("{path}/{}", escape(k)), out);
239 }
240 }
241 Value::Object(map) => {
242 if let Some(item) = map.get(&head) {
243 select(item, rest, format!("{path}/{}", escape(&head)), out);
244 }
245 }
246 _ => {}
247 }
248}
249
250fn escape(segment: &str) -> String {
251 segment.replace('~', "~0").replace('/', "~1")
252}
253
254fn strings<'a>(v: &'a Value, path: String, out: &mut Vec<(String, &'a str)>) {
256 match v {
257 Value::String(s) => out.push((path, s)),
258 Value::Array(items) => {
259 for (i, item) in items.iter().enumerate() {
260 strings(item, format!("{path}/{i}"), out);
261 }
262 }
263 Value::Object(map) => {
264 for (k, item) in map {
265 strings(item, format!("{path}/{}", escape(k)), out);
266 }
267 }
268 _ => {}
269 }
270}
271
272fn html_regex() -> &'static Regex {
273 static RE: OnceLock<Regex> = OnceLock::new();
274 RE.get_or_init(|| {
275 Regex::new(r"(?is)</?[a-z][a-z0-9-]*(?:\s[^<>]*)?/?>|<!--|<!\[cdata\[")
276 .expect("valid regex")
277 })
278}
279
280pub fn contains_html(text: &str) -> bool {
285 html_regex().is_match(text)
286}
287
288pub fn strip_html(text: &str) -> String {
295 html_regex().replace_all(text, "").into_owned()
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use serde_json::json;
302
303 #[test]
304 fn detects_tags_comments_and_cdata() {
305 assert!(contains_html("hi <b>there</b>"));
306 assert!(contains_html("<SCRIPT src=x>"));
307 assert!(contains_html("<!-- hidden -->"));
308 assert!(contains_html("<![CDATA[x]]>"));
309 assert!(!contains_html("a < b and c > d"));
310 assert!(!contains_html("1 <2> 3")); }
312
313 #[test]
314 fn strip_removes_tags_only() {
315 assert_eq!(strip_html("<p>Hello <b>world</b></p>"), "Hello world");
316 }
317
318 #[test]
319 fn pointer_wildcards_select_array_elements() {
320 let v = json!({"steps": [{"action": "read"}, {"action": "delete"}]});
321 let mut out = Vec::new();
322 select(&v, "steps/*/action", String::new(), &mut out);
323 let paths: Vec<_> = out.iter().map(|(p, _)| p.as_str()).collect();
324 assert_eq!(paths, ["/steps/0/action", "/steps/1/action"]);
325 }
326
327 #[test]
328 fn pointer_escapes_are_decoded() {
329 let v = json!({"a/b": {"~": 1}});
330 let mut out = Vec::new();
331 select(&v, "a~1b/~0", String::new(), &mut out);
332 assert_eq!(out.len(), 1);
333 assert_eq!(out[0].0, "/a~1b/~0");
334 }
335
336 #[test]
337 fn rejects_bad_pointer() {
338 let err = OutputValidator::new(OutputPolicy {
339 allow: vec![AllowList {
340 pointer: "steps".into(),
341 values: vec![],
342 }],
343 ..Default::default()
344 })
345 .unwrap_err();
346 assert!(matches!(err, Error::InvalidPointer(_)));
347 }
348}