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 if let Some(Token::Ident(name)) = tokens.get(index + 1) {
313 if let Some(open) = find_open_brace(tokens, index + 2, tokens.len()) {
314 if let Some(close) = match_brace(tokens, open, tokens.len()) {
315 functions.insert(
316 name.clone(),
317 FnBody {
318 start: open + 1,
319 end: close,
320 },
321 );
322 index = close + 1;
323 continue;
324 }
325 }
326 }
327 }
328 index += 1;
329 }
330 functions
331}
332
333#[cfg(test)]
334mod tests {
335 use super::{DeterminismError, ViolationKind, analyze_determinism};
336
337 #[test]
338 fn clean_workflow_has_no_violations() -> Result<(), Box<dyn std::error::Error>> {
339 let source = "import aion/workflow\n\
340 pub fn run(input) {\n \
341 let assert Ok(at) = workflow.now()\n \
342 let assert Ok(seed) = workflow.random()\n \
343 workflow.run(wrappers.charge_activity(input))\n}\n";
344 assert!(analyze_determinism(source, "run")?.is_empty());
345 Ok(())
346 }
347
348 #[test]
349 fn direct_wall_clock_call_is_flagged() -> Result<(), Box<dyn std::error::Error>> {
350 let source = "pub fn run(input) {\n \
351 let now = erlang.system_time(1000)\n \
352 workflow.run(wrappers.charge_activity(input))\n}\n";
353 let violations = analyze_determinism(source, "run")?;
354 assert_eq!(violations.len(), 1);
355 assert_eq!(violations[0].call, "erlang.system_time");
356 assert_eq!(violations[0].kind, ViolationKind::WallClock);
357 assert_eq!(violations[0].function, "run");
358 Ok(())
359 }
360
361 #[test]
362 fn entropy_in_a_reachable_helper_is_flagged() -> Result<(), Box<dyn std::error::Error>> {
363 let source = "pub fn run(input) {\n \
364 let id = make_id(input)\n \
365 workflow.run(wrappers.charge_activity(id))\n}\n\
366 fn make_id(input) {\n float.random()\n}\n";
367 let violations = analyze_determinism(source, "run")?;
368 assert_eq!(violations.len(), 1, "{violations:?}");
369 assert_eq!(violations[0].call, "float.random");
370 assert_eq!(violations[0].kind, ViolationKind::Entropy);
371 assert_eq!(violations[0].function, "make_id");
372 Ok(())
373 }
374
375 #[test]
376 fn entropy_in_a_helper_passed_as_a_value_is_flagged() -> Result<(), Box<dyn std::error::Error>>
377 {
378 let source = "pub fn run(input) {\n \
384 let _ = list.map(input, tainted)\n \
385 workflow.run(wrappers.charge_activity(input))\n}\n\
386 fn tainted(item) {\n float.random()\n}\n";
387 let violations = analyze_determinism(source, "run")?;
388 assert_eq!(violations.len(), 1, "{violations:?}");
389 assert_eq!(violations[0].call, "float.random");
390 assert_eq!(violations[0].kind, ViolationKind::Entropy);
391 assert_eq!(violations[0].function, "tainted");
392 Ok(())
393 }
394
395 #[test]
396 fn unreachable_helper_violation_is_not_flagged() -> Result<(), Box<dyn std::error::Error>> {
397 let source = "pub fn run(input) {\n \
400 workflow.run(wrappers.charge_activity(input))\n}\n\
401 fn tainted(input) {\n int.random()\n}\n";
402 assert!(analyze_determinism(source, "run")?.is_empty());
403 Ok(())
404 }
405
406 #[test]
407 fn forbidden_word_inside_a_string_literal_is_not_flagged()
408 -> Result<(), Box<dyn std::error::Error>> {
409 let source = "pub fn run(input) {\n \
412 log(\"erlang.system_time is forbidden here\")\n \
413 workflow.run(wrappers.charge_activity(input))\n}\n";
414 assert!(analyze_determinism(source, "run")?.is_empty());
415 Ok(())
416 }
417
418 #[test]
419 fn missing_entry_function_is_a_loud_error() {
420 let source = "fn helper() {\n Nil\n}\n";
421 let result = analyze_determinism(source, "run");
422 assert_eq!(
423 result,
424 Err(DeterminismError::EntryFunctionNotFound {
425 function: "run".to_owned(),
426 })
427 );
428 }
429
430 #[test]
431 fn mutually_recursive_helpers_terminate() -> Result<(), Box<dyn std::error::Error>> {
432 let source = "pub fn run(input) {\n ping(input)\n}\n\
433 fn ping(input) {\n pong(input)\n}\n\
434 fn pong(input) {\n ping(input)\n os.system_time(1)\n}\n";
435 let violations = analyze_determinism(source, "run")?;
436 assert_eq!(violations.len(), 1);
437 assert_eq!(violations[0].call, "os.system_time");
438 Ok(())
439 }
440
441 #[test]
442 fn multiple_distinct_calls_are_all_reported() -> Result<(), Box<dyn std::error::Error>> {
443 let source = "pub fn run(input) {\n \
444 let a = os.system_time(1)\n \
445 let b = crypto.strong_rand_bytes(16)\n \
446 let c = erlang.unique_integer([])\n}\n";
447 let violations = analyze_determinism(source, "run")?;
448 let calls: Vec<&str> = violations.iter().map(|v| v.call.as_str()).collect();
449 assert_eq!(
450 calls,
451 vec![
452 "crypto.strong_rand_bytes",
453 "erlang.unique_integer",
454 "os.system_time",
455 ]
456 );
457 Ok(())
458 }
459}