1use std::fmt;
41use std::sync::atomic::AtomicBool;
42use std::sync::Arc;
43
44use tatara_lisp::{Sexp, Spanned};
45use tatara_lisp_eval::persist::Map;
46use tatara_lisp_eval::value::MapKey;
47use tatara_lisp_eval::vm::Budget;
48use tatara_lisp_eval::Value;
49
50use crate::hosted::{load_hosted_in_frame, CallError, FramedError, Hosted};
51use crate::uses::{Entry, Loader};
52
53pub const RULE_STEPS: usize = 10_000_000;
56
57const EVALUATES: &[&str] = &["eval", "require", "provide"];
60
61#[must_use]
63pub fn permits(name: &str) -> bool {
64 let host = blue_lang_waku::Capability::ALL
65 .iter()
66 .any(|c| c.is_host_effect() && c.grants(name));
67 let prints = crate::docs::doc_of(name).is_some_and(|d| d.topic == crate::docs::Topic::Output);
68 !host && !prints && !EVALUATES.contains(&name)
69}
70
71#[derive(Clone, Debug, PartialEq)]
73pub struct RawFinding {
74 pub at: Vec<usize>,
76 pub message: String,
77 pub help: Option<String>,
78 pub fix: Option<Sexp>,
80}
81
82#[derive(Clone, Debug, PartialEq)]
84pub enum RuleFailure {
85 Load(String),
87 Escapes(Vec<String>),
89 Undefined(String),
91 Exhausted(String),
93 Raised(String),
95 Shape(String),
97}
98
99impl fmt::Display for RuleFailure {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 match self {
102 RuleFailure::Load(m) => write!(f, "its program did not load: {m}"),
103 RuleFailure::Escapes(names) => write!(
104 f,
105 "it names {} outside the rule frame: {}",
106 if names.len() == 1 { "a name" } else { "names" },
107 names.join(", ")
108 ),
109 RuleFailure::Undefined(m) => write!(f, "{m}"),
110 RuleFailure::Exhausted(m) => write!(f, "it ran past its budget: {m}"),
111 RuleFailure::Raised(m) => write!(f, "it raised: {m}"),
112 RuleFailure::Shape(m) => write!(f, "it returned {m}"),
113 }
114 }
115}
116
117pub struct RuleProgram {
124 hosted: Hosted<()>,
125 halt: Arc<AtomicBool>,
126}
127
128fn past_memory(bound: u64) -> RuleFailure {
129 RuleFailure::Exhausted(blue_lang_proc::memory_refusal(bound))
130}
131
132fn budget(steps: usize) -> Budget {
133 Budget {
134 fuel: Some(steps),
135 max_depth: Some(crate::execution_bounds().max_call_depth),
136 quantum: None,
137 }
138}
139
140impl RuleProgram {
141 pub fn load(entry: Entry<'_>, loader: &dyn Loader, steps: usize) -> Result<Self, RuleFailure> {
148 let halt = Arc::new(AtomicBool::new(false));
149 let bound = crate::execution_bounds().guest_max_alloc_bytes;
150 let own_halt = |interp: &mut tatara_lisp_eval::Interpreter<()>| {
151 interp.set_interrupt(Arc::clone(&halt));
152 };
153 let (loaded, over) = crate::memory::held(bound, &halt, || {
154 load_hosted_in_frame(entry, loader, &mut (), own_halt, &permits, budget(steps))
155 });
156 if let Some(bound) = over {
157 return Err(past_memory(bound));
158 }
159 match loaded {
160 Ok(hosted) => Ok(Self { hosted, halt }),
161 Err(FramedError::Escapes { names }) => Err(RuleFailure::Escapes(names)),
162 Err(FramedError::Run(e)) => Err(RuleFailure::Load(e.to_string())),
163 }
164 }
165
166 pub fn run(
172 &mut self,
173 def: &str,
174 form: &Spanned,
175 steps: usize,
176 ) -> Result<Vec<RawFinding>, RuleFailure> {
177 let arg = tatara_lisp_eval::code::spanned_to_value(form);
178 let bound = crate::execution_bounds().guest_max_alloc_bytes;
179 let hosted = &mut self.hosted;
180 let (called, over) = crate::memory::held(bound, &self.halt, || {
181 hosted.call_budgeted(def, vec![arg], &mut (), budget(steps))
182 });
183 if let Some(bound) = over {
184 return Err(past_memory(bound));
185 }
186 let value = called.map_err(|e| match e {
187 CallError::Exhausted { .. } => {
188 RuleFailure::Exhausted(crate::pipeline::display_keys(&e.to_string()))
189 }
190 CallError::Raised { .. } => {
191 RuleFailure::Raised(crate::pipeline::display_keys(&e.to_string()))
192 }
193 CallError::Undefined(_) | CallError::NotAFunction { .. } => {
194 RuleFailure::Undefined(e.to_string())
195 }
196 })?;
197 findings_of(&value)
198 }
199}
200
201fn findings_of(value: &Value) -> Result<Vec<RawFinding>, RuleFailure> {
202 match value {
203 Value::Nil => Ok(Vec::new()),
204 Value::List(items) => items.iter().map(finding_of).collect(),
205 other => Err(RuleFailure::Shape(format!(
206 "{}, where a list of findings belongs",
207 other.type_name()
208 ))),
209 }
210}
211
212fn field<'a>(m: &'a Map, key: &str) -> Option<&'a Value> {
213 m.get(&MapKey::Keyword(std::sync::Arc::from(key)))
214 .filter(|v| !matches!(v, Value::Nil))
215}
216
217fn finding_of(v: &Value) -> Result<RawFinding, RuleFailure> {
218 let Value::Map(m) = v else {
219 return Err(RuleFailure::Shape(format!(
220 "a finding that is {}, not a map",
221 v.type_name()
222 )));
223 };
224 let at = match field(m, "at") {
225 Some(Value::List(xs)) => xs
226 .iter()
227 .map(|x| match x {
228 Value::Int(n) if *n >= 0 => usize::try_from(*n).ok(),
229 _ => None,
230 })
231 .collect::<Option<Vec<usize>>>()
232 .ok_or_else(|| RuleFailure::Shape("an `at` that is not a list of indices".into()))?,
233 Some(Value::Nil) | None => Vec::new(),
234 Some(other) => {
235 return Err(RuleFailure::Shape(format!(
236 "an `at` that is {}, not a list of indices",
237 other.type_name()
238 )))
239 }
240 };
241 let text = |key: &str, required: bool| -> Result<Option<String>, RuleFailure> {
242 match field(m, key) {
243 Some(Value::Str(s)) => Ok(Some(s.to_string())),
244 None if !required => Ok(None),
245 None => Err(RuleFailure::Shape(format!("a finding with no `{key}`"))),
246 Some(other) => Err(RuleFailure::Shape(format!(
247 "a `{key}` that is {}, not a string",
248 other.type_name()
249 ))),
250 }
251 };
252 let message = text("message", true)?.unwrap_or_default();
253 let help = text("help", false)?;
254 let fix = match field(m, "fix") {
255 None => None,
256 Some(tree) => Some(
257 tatara_lisp_eval::code::value_to_spanned(tree, tatara_lisp::Span::synthetic())
258 .map_err(|e| RuleFailure::Shape(format!("a `fix` that is not a tree: {e}")))?
259 .to_sexp(),
260 ),
261 };
262 Ok(RawFinding {
263 at,
264 message,
265 help,
266 fix,
267 })
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::uses::NoLoader;
274
275 fn form(src: &str) -> Spanned {
276 blue_lang_syntax::parse_program_tree(src)
277 .expect("parse")
278 .remove(0)
279 }
280
281 fn load(src: &str) -> Result<RuleProgram, RuleFailure> {
282 RuleProgram::load(Entry::anonymous(src), &NoLoader, RULE_STEPS)
283 }
284
285 #[test]
290 fn the_rule_frame_refuses_the_host_printing_and_eval() {
291 for refused in [
292 "read_file",
293 "rm_rf",
294 "exec_capture",
295 "getenv",
296 "now",
297 "http_request",
298 ] {
299 assert!(!permits(refused), "{refused}");
300 }
301 for refused in ["write_stdout", "println", "eval", "require"] {
302 assert!(!permits(refused), "{refused}");
303 }
304 for admitted in [
305 "map", "filter", "first", "to_s", "symbol?", "list", "define",
306 ] {
307 assert!(permits(admitted), "{admitted}");
308 }
309 }
310
311 #[test]
312 fn a_rule_reports_findings_with_paths_messages_and_fixes() {
313 let mut p = load(
314 "def r(form)\n tree = quote\n b(unquote(nth(2, form)))\n end\n [{at: [2], message: \"two\", help: \"h\", fix: tree}]\nend\n",
315 )
316 .expect("loads");
317 let got = p.run("r", &form("f(1, 2)\n"), RULE_STEPS).expect("runs");
318 assert_eq!(got.len(), 1);
319 assert_eq!(got[0].at, vec![2]);
320 assert_eq!(got[0].message, "two");
321 assert_eq!(got[0].help.as_deref(), Some("h"));
322 assert_eq!(
323 got[0].fix.as_ref().map(ToString::to_string).as_deref(),
324 Some("(b 2)")
325 );
326 }
327
328 #[test]
329 fn a_rule_receives_the_form_as_data() {
330 let mut p = load(
331 "def r(form)\n if symbol?(first(form)) && to_s(first(form)) == \"f\"\n [{at: [], message: \"a call to f\"}]\n else\n []\n end\nend\n",
332 )
333 .expect("loads");
334 assert_eq!(p.run("r", &form("f(1)\n"), RULE_STEPS).unwrap().len(), 1);
335 assert!(p.run("r", &form("g(1)\n"), RULE_STEPS).unwrap().is_empty());
336 }
337
338 #[test]
347 fn a_rule_naming_a_refused_name_is_refused_by_the_frame() {
348 let err = load("def r(form)\n display(form)\n eval(form)\nend\n")
349 .err()
350 .expect("refused");
351 assert_eq!(
352 err,
353 RuleFailure::Escapes(vec!["display".into(), "eval".into()])
354 );
355 }
356
357 #[test]
362 fn a_runaway_rule_is_exhausted_not_a_crash() {
363 let mut p = load("def spin(n)\n spin(n + 1)\nend\n\ndef r(form)\n spin(0)\nend\n")
364 .expect("loads");
365 let err = p.run("r", &form("f(1)\n"), 10_000).unwrap_err();
366 assert!(matches!(err, RuleFailure::Exhausted(_)), "{err:?}");
367 assert!(err.to_string().contains("budget"), "{err}");
368 }
369
370 #[test]
371 fn a_value_that_is_not_findings_is_a_shape_failure() {
372 for body in ["1", "[1]", "[{message: \"m\", at: \"x\"}]", "[{at: []}]"] {
373 let mut p = load(&format!("def r(form)\n {body}\nend\n")).expect("loads");
374 let err = p.run("r", &form("f(1)\n"), RULE_STEPS).unwrap_err();
375 assert!(matches!(err, RuleFailure::Shape(_)), "{body}: {err:?}");
376 }
377 }
378}