1use std::collections::{BTreeMap, BTreeSet};
36
37use super::reader::{find_open_brace, match_brace};
38use super::scan::{Token, tokenise};
39
40struct ForbiddenCall {
43 qualifier: &'static str,
48 member: &'static str,
50 kind: ViolationKind,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
56pub enum ViolationKind {
57 WallClock,
60 Entropy,
63}
64
65impl ViolationKind {
66 #[must_use]
68 pub fn remedy(self) -> &'static str {
69 match self {
70 ViolationKind::WallClock => {
71 "use the recorded `workflow.now()` instead of reading the wall clock"
72 }
73 ViolationKind::Entropy => {
74 "use the seeded `workflow.random()` / `workflow.random_int(..)` \
75 instead of drawing entropy"
76 }
77 }
78 }
79}
80
81const FORBIDDEN_CALLS: &[ForbiddenCall] = &[
85 ForbiddenCall {
89 qualifier: "erlang",
90 member: "system_time",
91 kind: ViolationKind::WallClock,
92 },
93 ForbiddenCall {
94 qualifier: "erlang",
95 member: "monotonic_time",
96 kind: ViolationKind::WallClock,
97 },
98 ForbiddenCall {
99 qualifier: "erlang",
100 member: "now",
101 kind: ViolationKind::WallClock,
102 },
103 ForbiddenCall {
104 qualifier: "erlang",
105 member: "timestamp",
106 kind: ViolationKind::WallClock,
107 },
108 ForbiddenCall {
109 qualifier: "erlang",
110 member: "unique_integer",
111 kind: ViolationKind::Entropy,
112 },
113 ForbiddenCall {
114 qualifier: "os",
115 member: "system_time",
116 kind: ViolationKind::WallClock,
117 },
118 ForbiddenCall {
119 qualifier: "os",
120 member: "timestamp",
121 kind: ViolationKind::WallClock,
122 },
123 ForbiddenCall {
124 qualifier: "os",
125 member: "perf_counter",
126 kind: ViolationKind::WallClock,
127 },
128 ForbiddenCall {
131 qualifier: "os",
132 member: "erlang_timestamp",
133 kind: ViolationKind::WallClock,
134 },
135 ForbiddenCall {
137 qualifier: "rand",
138 member: "uniform",
139 kind: ViolationKind::Entropy,
140 },
141 ForbiddenCall {
142 qualifier: "rand",
143 member: "uniform_real",
144 kind: ViolationKind::Entropy,
145 },
146 ForbiddenCall {
147 qualifier: "rand",
148 member: "bytes",
149 kind: ViolationKind::Entropy,
150 },
151 ForbiddenCall {
152 qualifier: "crypto",
153 member: "strong_rand_bytes",
154 kind: ViolationKind::Entropy,
155 },
156 ForbiddenCall {
159 qualifier: "float",
160 member: "random",
161 kind: ViolationKind::Entropy,
162 },
163 ForbiddenCall {
164 qualifier: "int",
165 member: "random",
166 kind: ViolationKind::Entropy,
167 },
168];
169
170#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
172pub struct Violation {
173 pub function: String,
176 pub call: String,
178 pub kind: ViolationKind,
180}
181
182#[derive(thiserror::Error, Debug, PartialEq, Eq)]
184pub enum DeterminismError {
185 #[error(
189 "entry function `{function}` is not defined in the workflow source; the determinism \
190 analysis requires its body to walk from"
191 )]
192 EntryFunctionNotFound {
193 function: String,
195 },
196}
197
198#[derive(Clone, Copy)]
200struct FnBody {
201 start: usize,
202 end: usize,
203}
204
205pub fn analyze_determinism(
217 source: &str,
218 entry_function: &str,
219) -> Result<Vec<Violation>, DeterminismError> {
220 let tokens = tokenise(source);
221 let functions = map_functions(&tokens);
222 if !functions.contains_key(entry_function) {
223 return Err(DeterminismError::EntryFunctionNotFound {
224 function: entry_function.to_owned(),
225 });
226 }
227
228 let mut violations: BTreeSet<Violation> = BTreeSet::new();
229 let mut visited: BTreeSet<String> = BTreeSet::new();
230 walk(
231 &tokens,
232 &functions,
233 entry_function,
234 &mut visited,
235 &mut violations,
236 );
237 Ok(violations.into_iter().collect())
238}
239
240fn walk(
244 tokens: &[Token],
245 functions: &BTreeMap<String, FnBody>,
246 function: &str,
247 visited: &mut BTreeSet<String>,
248 violations: &mut BTreeSet<Violation>,
249) {
250 if !visited.insert(function.to_owned()) {
251 return;
252 }
253 let Some(body) = functions.get(function).copied() else {
254 return;
255 };
256 let mut callees: Vec<String> = Vec::new();
260 let upper = body.end.min(tokens.len());
261 let mut index = body.start;
262 let mut depth: usize = 0;
263 while index < upper {
264 match &tokens[index] {
265 Token::OpenParen => depth += 1,
266 Token::CloseParen => depth = depth.saturating_sub(1),
267 Token::Qualified { left, right } => {
268 if let Some(forbidden) = match_forbidden(left, right) {
269 violations.insert(Violation {
270 function: function.to_owned(),
271 call: format!("{left}.{right}"),
272 kind: forbidden.kind,
273 });
274 }
275 }
276 Token::Ident(name) if functions.contains_key(name) => {
283 let applied = matches!(tokens.get(index + 1), Some(Token::OpenParen));
284 if applied || depth >= 1 {
285 callees.push(name.clone());
286 }
287 }
288 _ => {}
289 }
290 index += 1;
291 }
292 for callee in callees {
293 walk(tokens, functions, &callee, visited, violations);
294 }
295}
296
297fn match_forbidden(qualifier: &str, member: &str) -> Option<&'static ForbiddenCall> {
299 FORBIDDEN_CALLS
300 .iter()
301 .find(|call| call.qualifier == qualifier && call.member == member)
302}
303
304fn map_functions(tokens: &[Token]) -> BTreeMap<String, FnBody> {
308 let mut functions = BTreeMap::new();
309 let mut index = 0;
310 while index < tokens.len() {
311 if matches!(&tokens[index], Token::Ident(word) if word == "fn")
312 && let Some(Token::Ident(name)) = tokens.get(index + 1)
313 && let Some(open) = find_open_brace(tokens, index + 2, tokens.len())
314 && let Some(close) = match_brace(tokens, open, tokens.len())
315 {
316 functions.insert(
317 name.clone(),
318 FnBody {
319 start: open + 1,
320 end: close,
321 },
322 );
323 index = close + 1;
324 continue;
325 }
326 index += 1;
327 }
328 functions
329}
330
331#[cfg(test)]
332mod tests {
333 use super::{DeterminismError, ViolationKind, analyze_determinism};
334
335 #[test]
336 fn clean_workflow_has_no_violations() -> Result<(), Box<dyn std::error::Error>> {
337 let source = "import aion/workflow\n\
338 pub fn run(input) {\n \
339 let assert Ok(at) = workflow.now()\n \
340 let assert Ok(seed) = workflow.random()\n \
341 workflow.run(wrappers.charge_activity(input))\n}\n";
342 assert!(analyze_determinism(source, "run")?.is_empty());
343 Ok(())
344 }
345
346 #[test]
347 fn direct_wall_clock_call_is_flagged() -> Result<(), Box<dyn std::error::Error>> {
348 let source = "pub fn run(input) {\n \
349 let now = erlang.system_time(1000)\n \
350 workflow.run(wrappers.charge_activity(input))\n}\n";
351 let violations = analyze_determinism(source, "run")?;
352 assert_eq!(violations.len(), 1);
353 assert_eq!(violations[0].call, "erlang.system_time");
354 assert_eq!(violations[0].kind, ViolationKind::WallClock);
355 assert_eq!(violations[0].function, "run");
356 Ok(())
357 }
358
359 #[test]
360 fn entropy_in_a_reachable_helper_is_flagged() -> Result<(), Box<dyn std::error::Error>> {
361 let source = "pub fn run(input) {\n \
362 let id = make_id(input)\n \
363 workflow.run(wrappers.charge_activity(id))\n}\n\
364 fn make_id(input) {\n float.random()\n}\n";
365 let violations = analyze_determinism(source, "run")?;
366 assert_eq!(violations.len(), 1, "{violations:?}");
367 assert_eq!(violations[0].call, "float.random");
368 assert_eq!(violations[0].kind, ViolationKind::Entropy);
369 assert_eq!(violations[0].function, "make_id");
370 Ok(())
371 }
372
373 #[test]
374 fn entropy_in_a_helper_passed_as_a_value_is_flagged() -> Result<(), Box<dyn std::error::Error>>
375 {
376 let source = "pub fn run(input) {\n \
382 let _ = list.map(input, tainted)\n \
383 workflow.run(wrappers.charge_activity(input))\n}\n\
384 fn tainted(item) {\n float.random()\n}\n";
385 let violations = analyze_determinism(source, "run")?;
386 assert_eq!(violations.len(), 1, "{violations:?}");
387 assert_eq!(violations[0].call, "float.random");
388 assert_eq!(violations[0].kind, ViolationKind::Entropy);
389 assert_eq!(violations[0].function, "tainted");
390 Ok(())
391 }
392
393 #[test]
394 fn unreachable_helper_violation_is_not_flagged() -> Result<(), Box<dyn std::error::Error>> {
395 let source = "pub fn run(input) {\n \
398 workflow.run(wrappers.charge_activity(input))\n}\n\
399 fn tainted(input) {\n int.random()\n}\n";
400 assert!(analyze_determinism(source, "run")?.is_empty());
401 Ok(())
402 }
403
404 #[test]
405 fn forbidden_word_inside_a_string_literal_is_not_flagged()
406 -> Result<(), Box<dyn std::error::Error>> {
407 let source = "pub fn run(input) {\n \
410 log(\"erlang.system_time is forbidden here\")\n \
411 workflow.run(wrappers.charge_activity(input))\n}\n";
412 assert!(analyze_determinism(source, "run")?.is_empty());
413 Ok(())
414 }
415
416 #[test]
417 fn missing_entry_function_is_a_loud_error() {
418 let source = "fn helper() {\n Nil\n}\n";
419 let result = analyze_determinism(source, "run");
420 assert_eq!(
421 result,
422 Err(DeterminismError::EntryFunctionNotFound {
423 function: "run".to_owned(),
424 })
425 );
426 }
427
428 #[test]
429 fn mutually_recursive_helpers_terminate() -> Result<(), Box<dyn std::error::Error>> {
430 let source = "pub fn run(input) {\n ping(input)\n}\n\
431 fn ping(input) {\n pong(input)\n}\n\
432 fn pong(input) {\n ping(input)\n os.system_time(1)\n}\n";
433 let violations = analyze_determinism(source, "run")?;
434 assert_eq!(violations.len(), 1);
435 assert_eq!(violations[0].call, "os.system_time");
436 Ok(())
437 }
438
439 #[test]
440 fn multiple_distinct_calls_are_all_reported() -> Result<(), Box<dyn std::error::Error>> {
441 let source = "pub fn run(input) {\n \
442 let a = os.system_time(1)\n \
443 let b = crypto.strong_rand_bytes(16)\n \
444 let c = erlang.unique_integer([])\n}\n";
445 let violations = analyze_determinism(source, "run")?;
446 let calls: Vec<&str> = violations.iter().map(|v| v.call.as_str()).collect();
447 assert_eq!(
448 calls,
449 vec![
450 "crypto.strong_rand_bytes",
451 "erlang.unique_integer",
452 "os.system_time",
453 ]
454 );
455 Ok(())
456 }
457}