1#![forbid(unsafe_code)]
39#![warn(missing_docs)]
40
41use std::collections::BTreeSet;
42use syn::{visit::Visit, ExprPath, ExprUnsafe, ItemFn, Path};
43
44#[derive(Debug, Clone)]
65pub struct Policy {
66 pub denied_paths: BTreeSet<String>,
75 pub denied_prefixes: BTreeSet<String>,
81 pub deny_unsafe: bool,
86}
87
88impl Policy {
89 pub fn empty() -> Self {
91 Self {
92 denied_paths: BTreeSet::new(),
93 denied_prefixes: BTreeSet::new(),
94 deny_unsafe: false,
95 }
96 }
97
98 pub fn deny_compute_impurity() -> Self {
102 let mut denied_paths = BTreeSet::new();
104 for p in [
105 "std::time::Instant::now",
107 "std::time::SystemTime::now",
108 "std::time::UNIX_EPOCH",
109 "chrono::Utc::now",
110 "chrono::Local::now",
111 "minstant::Instant::now",
112 "quanta::Clock::now",
113 "coarsetime::Instant::now",
114 "instant::Instant::now",
115 "rand::random",
121 "rand::thread_rng",
122 "rand::rngs::OsRng",
123 "rand::rngs::ThreadRng",
124 "getrandom::getrandom",
125 "getrandom::fill", "rdrand::RdRand",
127 "std::io::stdin",
129 "std::io::stdout",
130 "std::io::stderr",
131 ] {
132 denied_paths.insert(p.to_string());
133 }
134 let mut denied_prefixes = BTreeSet::new();
136 for p in [
137 "std::fs",
139 "std::net",
140 "std::process",
141 "std::env",
142 "tokio::fs",
143 "tokio::net",
144 "tokio::io",
145 "tokio::time",
146 "async_std::fs",
147 "async_std::net",
148 "async_std::io",
149 "async_std::task",
150 "mio",
151 "socket2",
152 "libc",
154 ] {
155 denied_prefixes.insert(p.to_string());
156 }
157 Self {
158 denied_paths,
159 denied_prefixes,
160 deny_unsafe: true,
165 }
166 }
167}
168
169impl Default for Policy {
170 fn default() -> Self {
171 Self::deny_compute_impurity()
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct PurityViolation {
180 pub denied_path: String,
183 pub site: String,
185 pub reason: &'static str,
187}
188
189pub fn check_purity(item: &ItemFn, policy: &Policy) -> Vec<PurityViolation> {
191 let mut visitor = PurityVisitor {
192 policy,
193 violations: Vec::new(),
194 };
195 visitor.visit_item_fn(item);
196 visitor.violations
197}
198
199pub fn check_purity_default(item: &ItemFn) -> Vec<PurityViolation> {
201 check_purity(item, &Policy::deny_compute_impurity())
202}
203
204struct PurityVisitor<'p> {
205 policy: &'p Policy,
206 violations: Vec<PurityViolation>,
207}
208
209impl<'ast, 'p> Visit<'ast> for PurityVisitor<'p> {
210 fn visit_expr_path(&mut self, node: &'ast ExprPath) {
222 let path_str = path_to_string(&node.path);
223 if let Some((denied, kind)) = self.match_against_deny_list(&node.path, &path_str) {
224 self.violations.push(PurityViolation {
225 denied_path: denied.to_string(),
226 site: format!("{} ({kind})", path_str),
227 reason: classify_reason(denied),
228 });
229 }
230 syn::visit::visit_expr_path(self, node);
231 }
232
233 fn visit_expr_unsafe(&mut self, node: &'ast ExprUnsafe) {
237 if self.policy.deny_unsafe {
238 self.violations.push(PurityViolation {
239 denied_path: "unsafe-block".to_string(),
240 site: "unsafe { ... }".to_string(),
241 reason: "unsafe",
242 });
243 }
244 syn::visit::visit_expr_unsafe(self, node);
245 }
246}
247
248#[derive(Copy, Clone)]
249enum MatchKind {
250 Exact,
251 SingleIdentSuffix,
252 Prefix,
253}
254
255impl core::fmt::Display for MatchKind {
256 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
257 f.write_str(match self {
258 MatchKind::Exact => "exact",
259 MatchKind::SingleIdentSuffix => "imported-ident",
260 MatchKind::Prefix => "prefix",
261 })
262 }
263}
264
265impl<'p> PurityVisitor<'p> {
266 fn match_against_deny_list<'d>(
276 &'d self,
277 path: &Path,
278 path_str: &str,
279 ) -> Option<(&'d str, MatchKind)> {
280 if let Some(entry) = self.policy.denied_paths.get(path_str) {
281 return Some((entry.as_str(), MatchKind::Exact));
282 }
283 if path.segments.len() == 1 {
284 let ident = &path.segments[0].ident;
285 let needle = format!("::{ident}");
286 for denied in &self.policy.denied_paths {
287 if denied.ends_with(&needle) {
288 return Some((denied.as_str(), MatchKind::SingleIdentSuffix));
289 }
290 }
291 }
292 for prefix in &self.policy.denied_prefixes {
293 if path_str == prefix.as_str() || path_str.starts_with(&format!("{prefix}::")) {
294 return Some((prefix.as_str(), MatchKind::Prefix));
295 }
296 }
297 None
298 }
299}
300
301fn path_to_string(path: &Path) -> String {
302 let mut out = String::new();
303 if path.leading_colon.is_some() {
304 out.push_str("::");
305 }
306 let segs: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
307 out.push_str(&segs.join("::"));
308 out
309}
310
311fn classify_reason(denied: &str) -> &'static str {
312 if denied == "unsafe-block" {
313 return "unsafe";
314 }
315 if denied.contains("time::")
316 || denied.contains("chrono::")
317 || denied.contains("minstant::")
318 || denied.contains("quanta::")
319 || denied.contains("coarsetime::")
320 || denied.contains("instant::Instant")
321 || denied == "tokio::time"
322 {
323 "clock"
324 } else if denied.contains("rand")
325 || denied.contains("OsRng")
326 || denied.contains("getrandom")
327 || denied.contains("rdrand")
328 {
329 "rng"
330 } else if denied.contains("fs")
331 || denied.contains("net")
332 || denied.contains("io::")
333 || denied.ends_with("::io")
334 || denied.contains("process")
335 || denied.contains("env")
336 || denied == "mio"
337 || denied == "socket2"
338 || denied.contains("async_std::task")
339 {
340 "io"
341 } else if denied == "libc" || denied.contains("libc::") {
342 "ffi"
343 } else {
344 "other"
345 }
346}
347
348#[cfg(test)]
349#[allow(clippy::panic, clippy::unwrap_used)]
350mod tests {
351 use super::*;
352 use syn::parse_quote;
353
354 #[test]
355 fn pure_compute_passes() {
356 let f: ItemFn = parse_quote! {
357 fn compute(a: u32, b: u32) -> u32 {
358 a.wrapping_add(b).wrapping_mul(2)
359 }
360 };
361 let violations = check_purity_default(&f);
362 assert!(
363 violations.is_empty(),
364 "pure compute must not trigger violations: {violations:?}"
365 );
366 }
367
368 #[test]
369 fn instant_now_full_path_rejected() {
370 let f: ItemFn = parse_quote! {
371 fn compute() -> u128 {
372 let _now = std::time::Instant::now();
373 0
374 }
375 };
376 let violations = check_purity_default(&f);
377 assert_eq!(violations.len(), 1);
378 assert_eq!(violations[0].denied_path, "std::time::Instant::now");
379 assert_eq!(violations[0].reason, "clock");
380 }
381
382 #[test]
383 fn use_imported_thread_rng_single_ident_rejected() {
384 let f: ItemFn = parse_quote! {
385 fn compute() -> u32 {
386 let _r = thread_rng();
387 0
388 }
389 };
390 let violations = check_purity_default(&f);
391 assert_eq!(violations.len(), 1);
392 assert_eq!(violations[0].denied_path, "rand::thread_rng");
393 assert_eq!(violations[0].reason, "rng");
394 }
395
396 #[test]
397 fn os_rng_full_path_rejected() {
398 let f: ItemFn = parse_quote! {
399 fn compute() -> u32 {
400 let _ = rand::rngs::OsRng;
401 0
402 }
403 };
404 let violations = check_purity_default(&f);
405 assert!(violations
406 .iter()
407 .any(|v| v.denied_path == "rand::rngs::OsRng"));
408 }
409
410 #[test]
411 fn unix_epoch_constant_access_rejected() {
412 let f: ItemFn = parse_quote! {
413 fn compute() -> u128 {
414 let _ = std::time::UNIX_EPOCH;
415 0
416 }
417 };
418 let violations = check_purity_default(&f);
419 assert!(violations
420 .iter()
421 .any(|v| v.denied_path == "std::time::UNIX_EPOCH"));
422 }
423
424 #[test]
425 fn type_position_path_does_not_match() {
426 let f: ItemFn = parse_quote! {
427 fn compute() -> u32 {
428 let _x: Option<std::time::Instant> = None;
429 0
430 }
431 };
432 let violations = check_purity_default(&f);
433 assert!(
434 violations.is_empty(),
435 "type-position path must not match: {violations:?}"
436 );
437 }
438
439 #[test]
440 fn shell_defined_now_method_does_not_match() {
441 let f: ItemFn = parse_quote! {
442 fn compute(s: ShellState) -> u32 {
443 let _ = s.now();
444 0
445 }
446 };
447 let violations = check_purity_default(&f);
448 assert!(
449 violations.is_empty(),
450 "shell .now() method must not falsely trigger: {violations:?}"
451 );
452 }
453
454 #[test]
455 fn fs_namespace_prefix_rejected() {
456 let f: ItemFn = parse_quote! {
457 fn compute() -> u32 {
458 let _ = std::fs::read_to_string("/etc/passwd");
459 0
460 }
461 };
462 let violations = check_purity_default(&f);
463 assert!(
464 violations.iter().any(|v| v.denied_path == "std::fs"),
465 "std::fs::* prefix must trigger: {violations:?}"
466 );
467 assert!(violations.iter().any(|v| v.reason == "io"));
468 }
469
470 #[test]
471 fn net_namespace_prefix_rejected() {
472 let f: ItemFn = parse_quote! {
473 fn compute() -> u32 {
474 let _ = std::net::TcpStream::connect("0.0.0.0:1");
475 0
476 }
477 };
478 let violations = check_purity_default(&f);
479 assert!(violations.iter().any(|v| v.denied_path == "std::net"));
480 }
481
482 #[test]
483 fn process_namespace_prefix_rejected() {
484 let f: ItemFn = parse_quote! {
485 fn compute() -> u32 {
486 let _ = std::process::id();
487 0
488 }
489 };
490 let violations = check_purity_default(&f);
491 assert!(violations.iter().any(|v| v.denied_path == "std::process"));
492 }
493
494 #[test]
495 fn env_namespace_prefix_rejected() {
496 let f: ItemFn = parse_quote! {
497 fn compute() -> u32 {
498 let _ = std::env::var("HOME");
499 0
500 }
501 };
502 let violations = check_purity_default(&f);
503 assert!(violations.iter().any(|v| v.denied_path == "std::env"));
504 }
505
506 #[test]
507 fn libc_namespace_prefix_rejected() {
508 let f: ItemFn = parse_quote! {
509 fn compute() -> u32 {
510 let _ = libc::getpid();
511 0
512 }
513 };
514 let violations = check_purity_default(&f);
515 assert!(violations.iter().any(|v| v.denied_path == "libc"));
516 assert!(violations.iter().any(|v| v.reason == "ffi"));
517 }
518
519 #[test]
520 fn unsafe_block_rejected() {
521 let f: ItemFn = parse_quote! {
522 fn compute(x: u32) -> u32 {
523 unsafe {
524 let p = &x as *const u32;
525 *p
526 }
527 }
528 };
529 let violations = check_purity_default(&f);
530 assert!(
531 violations.iter().any(|v| v.denied_path == "unsafe-block"),
532 "unsafe block must trigger: {violations:?}"
533 );
534 assert!(violations.iter().any(|v| v.reason == "unsafe"));
535 }
536
537 #[test]
538 fn tokio_time_prefix_rejected() {
539 let f: ItemFn = parse_quote! {
540 fn compute() -> u32 {
541 let _ = tokio::time::Instant::now();
542 0
543 }
544 };
545 let violations = check_purity_default(&f);
546 assert!(
547 violations
548 .iter()
549 .any(|v| v.denied_path == "tokio::time" && v.reason == "clock"),
550 "tokio::time::* must trigger as clock: {violations:?}"
551 );
552 }
553
554 #[test]
555 fn tokio_io_prefix_classified_as_io() {
556 assert_eq!(classify_reason("tokio::io"), "io");
561 assert_eq!(classify_reason("async_std::io"), "io");
562 }
563
564 #[test]
565 fn classify_reason_categorises_correctly() {
566 assert_eq!(classify_reason("std::time::Instant::now"), "clock");
567 assert_eq!(classify_reason("rand::thread_rng"), "rng");
568 assert_eq!(classify_reason("std::fs"), "io");
569 assert_eq!(classify_reason("libc"), "ffi");
570 assert_eq!(classify_reason("unsafe-block"), "unsafe");
571 assert_eq!(classify_reason("blake3::hash"), "other");
572 }
573
574 #[test]
575 fn empty_policy_accepts_anything() {
576 let f: ItemFn = parse_quote! {
577 fn compute() -> u32 {
578 let _ = std::time::Instant::now();
579 let _ = rand::thread_rng();
580 let _ = std::fs::read_to_string("/etc/passwd");
581 unsafe { let _: u8 = 1; }
582 0
583 }
584 };
585 let violations = check_purity(&f, &Policy::empty());
586 assert!(violations.is_empty());
587 }
588
589 #[test]
590 fn local_fn_named_random_currently_false_positives() {
591 let f: ItemFn = parse_quote! {
598 fn compute() -> u32 {
599 fn random() -> u32 { 42 }
600 random()
601 }
602 };
603 let violations = check_purity_default(&f);
604 assert!(
605 violations.iter().any(|v| v.denied_path == "rand::random"),
606 "bare-ident `random()` is a known false positive"
607 );
608 }
609
610 #[test]
611 fn getrandom_fill_rejected() {
612 let f: ItemFn = parse_quote! {
613 fn compute(buf: &mut [u8]) -> () {
614 let _ = getrandom::fill(buf);
615 }
616 };
617 let violations = check_purity_default(&f);
618 assert!(violations
619 .iter()
620 .any(|v| v.denied_path == "getrandom::fill"));
621 }
622
623 #[test]
624 fn pure_with_blake3_passes() {
625 let f: ItemFn = parse_quote! {
626 fn compute(input: &[u8]) -> [u8; 32] {
627 let mut h = blake3::Hasher::new();
628 h.update(input);
629 *h.finalize().as_bytes()
630 }
631 };
632 let violations = check_purity_default(&f);
633 assert!(violations.is_empty());
634 }
635}