1use super::filter_entry;
14use super::utils::{strip_string_literals, StringLiteralState};
15use crate::detect_broadcast_unsync_payloads;
16use crate::{
17 Confidence, Exploitability, Finding, MirPackage, Rule, RuleMetadata, RuleOrigin, Severity,
18};
19use std::collections::HashSet;
20use std::ffi::OsStr;
21use std::fs;
22use std::path::Path;
23use walkdir::WalkDir;
24
25pub struct NonThreadSafeTestRule {
32 metadata: RuleMetadata,
33}
34
35impl NonThreadSafeTestRule {
36 pub fn new() -> Self {
37 Self {
38 metadata: RuleMetadata {
39 id: "RUSTCOLA074".to_string(),
40 name: "non-thread-safe-test".to_string(),
41 short_description: "Test function uses non-thread-safe types".to_string(),
42 full_description: "Detects test functions that use non-thread-safe types like Rc, RefCell, \
43 Cell, or raw pointers in ways that could cause issues when tests run in parallel. \
44 The Rust test framework runs tests concurrently by default, and using !Send or !Sync \
45 types with shared state (like static variables) can lead to data races or undefined \
46 behavior. Consider using thread-safe alternatives (Arc, Mutex, AtomicCell) or marking \
47 tests that require serialization with #[serial].".to_string(),
48 help_uri: Some("https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html".to_string()),
49 default_severity: Severity::Medium,
50 origin: RuleOrigin::BuiltIn,
51 cwe_ids: Vec::new(),
52 fix_suggestion: None,
53 exploitability: Exploitability::default(),
54 },
55 }
56 }
57
58 fn non_thread_safe_patterns() -> &'static [&'static str] {
60 &[
61 "Rc<",
62 "Rc::",
63 "RefCell<",
64 "RefCell::",
65 "Cell<",
66 "Cell::",
67 "UnsafeCell<",
68 "UnsafeCell::",
69 "*const ",
70 "*mut ",
71 ]
72 }
73
74 fn is_test_function(name: &str, signature: &str) -> bool {
76 let looks_like_test_name = name.contains("::test_")
77 || name.starts_with("test_")
78 || name.contains("::tests::")
79 || name.ends_with("_test");
80
81 let no_params = signature.contains("fn()")
82 || signature.contains("fn ()")
83 || (signature.contains('(') && signature.contains("()"));
84
85 looks_like_test_name && no_params
86 }
87
88 fn uses_non_thread_safe_types(body: &[String]) -> Vec<String> {
90 let mut evidence = Vec::new();
91 let patterns = Self::non_thread_safe_patterns();
92
93 for line in body {
94 let trimmed = line.trim();
95 if trimmed.starts_with("//") {
96 continue;
97 }
98
99 for pattern in patterns {
100 if trimmed.contains(pattern) {
101 evidence.push(trimmed.to_string());
102 break;
103 }
104 }
105 }
106
107 evidence
108 }
109
110 fn accesses_static_state(body: &[String]) -> bool {
112 body.iter().any(|line| {
113 let trimmed = line.trim();
114 trimmed.contains("static ")
115 || trimmed.contains("lazy_static!")
116 || trimmed.contains("thread_local!")
117 || trimmed.contains("GLOBAL")
118 || trimmed.contains("STATE")
119 })
120 }
121}
122
123impl Rule for NonThreadSafeTestRule {
124 fn metadata(&self) -> &RuleMetadata {
125 &self.metadata
126 }
127
128 fn evaluate(
129 &self,
130 package: &MirPackage,
131 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
132 ) -> Vec<Finding> {
133 let mut findings = Vec::new();
134
135 for function in &package.functions {
136 if !Self::is_test_function(&function.name, &function.signature) {
137 continue;
138 }
139
140 let non_thread_safe_usage = Self::uses_non_thread_safe_types(&function.body);
141
142 if !non_thread_safe_usage.is_empty() {
143 let severity = if Self::accesses_static_state(&function.body) {
144 Severity::High
145 } else {
146 self.metadata.default_severity
147 };
148
149 let limited_evidence: Vec<_> = non_thread_safe_usage.into_iter().take(5).collect();
150
151 findings.push(Finding {
152 rule_id: self.metadata.id.clone(),
153 rule_name: self.metadata.name.clone(),
154 severity,
155 message: format!(
156 "Test function `{}` uses non-thread-safe types (Rc, RefCell, Cell, raw pointers). \
157 Tests run in parallel by default; consider using thread-safe alternatives or #[serial].",
158 function.name
159 ),
160 function: function.name.clone(),
161 function_signature: function.signature.clone(),
162 evidence: limited_evidence,
163 span: function.span.clone(),
164 confidence: Confidence::Medium,
165 cwe_ids: Vec::new(),
166 fix_suggestion: None,
167 code_snippet: None,
168 exploitability: Exploitability::default(),
169 exploitability_score: Exploitability::default().score(),
170 ..Default::default()
171 });
172 }
173 }
174
175 findings
176 }
177}
178
179pub struct BlockingSleepInAsyncRule {
185 metadata: RuleMetadata,
186}
187
188impl BlockingSleepInAsyncRule {
189 pub fn new() -> Self {
190 Self {
191 metadata: RuleMetadata {
192 id: "RUSTCOLA037".to_string(),
193 name: "blocking-sleep-in-async".to_string(),
194 short_description: "Blocking sleep in async function".to_string(),
195 full_description: "Detects std::thread::sleep and other blocking sleep calls inside async functions. \
196 Blocking sleep in async contexts can stall the executor and prevent other tasks from running, \
197 potentially causing denial-of-service. Use async sleep (tokio::time::sleep, async_std::task::sleep, etc.) instead.".to_string(),
198 help_uri: Some("https://www.jetbrains.com/help/inspectopedia/RsSleepInsideAsyncFunction.html".to_string()),
199 default_severity: Severity::Medium,
200 origin: RuleOrigin::BuiltIn,
201 cwe_ids: Vec::new(),
202 fix_suggestion: None,
203 exploitability: Exploitability::default(),
204 },
205 }
206 }
207
208 fn blocking_sleep_patterns() -> &'static [&'static str] {
209 &["std::thread::sleep", "thread::sleep", "::thread::sleep"]
210 }
211}
212
213impl Rule for BlockingSleepInAsyncRule {
214 fn metadata(&self) -> &RuleMetadata {
215 &self.metadata
216 }
217
218 fn evaluate(
219 &self,
220 package: &MirPackage,
221 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
222 ) -> Vec<Finding> {
223 if package.crate_name == "mir-extractor" {
224 return Vec::new();
225 }
226
227 let mut findings = Vec::new();
228 let crate_root = Path::new(&package.crate_root);
229
230 if !crate_root.exists() {
231 return findings;
232 }
233
234 for entry in WalkDir::new(crate_root)
235 .into_iter()
236 .filter_entry(|e| filter_entry(e))
237 {
238 let entry = match entry {
239 Ok(e) => e,
240 Err(_) => continue,
241 };
242
243 if !entry.file_type().is_file() {
244 continue;
245 }
246
247 let path = entry.path();
248 if path.extension() != Some(OsStr::new("rs")) {
249 continue;
250 }
251
252 let rel_path = path
253 .strip_prefix(crate_root)
254 .unwrap_or(path)
255 .to_string_lossy()
256 .replace('\\', "/");
257
258 let content = match fs::read_to_string(path) {
259 Ok(c) => c,
260 Err(_) => continue,
261 };
262
263 let lines: Vec<&str> = content.lines().collect();
264
265 let mut in_async_fn = false;
266 let mut async_fn_start = 0;
267 let mut brace_depth = 0;
268 let mut async_fn_name = String::new();
269
270 for (idx, line) in lines.iter().enumerate() {
271 let trimmed = line.trim();
272
273 if trimmed.contains("async fn ") {
274 in_async_fn = true;
275 async_fn_start = idx;
276 brace_depth = 0;
277
278 if let Some(fn_pos) = trimmed.find("fn ") {
279 let after_fn = &trimmed[fn_pos + 3..];
280 if let Some(paren_pos) = after_fn.find('(') {
281 async_fn_name = after_fn[..paren_pos].trim().to_string();
282 }
283 }
284 }
285
286 if in_async_fn {
287 brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
288 brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
289
290 for pattern in Self::blocking_sleep_patterns() {
291 if trimmed.contains(pattern) {
292 let location = format!("{}:{}", rel_path, idx + 1);
293
294 findings.push(Finding::new(
295 self.metadata.id.clone(),
296 self.metadata.name.clone(),
297 self.metadata.default_severity,
298 format!(
299 "Blocking sleep in async function `{}` can stall executor",
300 async_fn_name
301 ),
302 location,
303 async_fn_name.clone(),
304 vec![trimmed.to_string()],
305 None,
306 ));
307 }
308 }
309
310 if brace_depth <= 0 && idx > async_fn_start {
311 in_async_fn = false;
312 }
313 }
314 }
315 }
316
317 findings
318 }
319}
320
321pub struct BlockingOpsInAsyncRule {
327 metadata: RuleMetadata,
328}
329
330impl BlockingOpsInAsyncRule {
331 pub fn new() -> Self {
332 Self {
333 metadata: RuleMetadata {
334 id: "RUSTCOLA093".to_string(),
335 name: "blocking-ops-in-async".to_string(),
336 short_description: "Blocking operation in async function".to_string(),
337 full_description: "Detects blocking operations inside async functions that can stall the async executor. \
338 This includes std::sync::Mutex::lock(), std::fs::* operations, std::net::* operations, and blocking I/O. \
339 These operations block the current thread, preventing the async runtime from executing other tasks. \
340 Use async alternatives (tokio::sync::Mutex, tokio::fs, tokio::net) or wrap blocking ops in spawn_blocking/block_in_place.".to_string(),
341 help_uri: None,
342 default_severity: Severity::Medium,
343 origin: RuleOrigin::BuiltIn,
344 cwe_ids: Vec::new(),
345 fix_suggestion: None,
346 exploitability: Exploitability::default(),
347 },
348 }
349 }
350
351 fn blocking_patterns() -> Vec<(&'static str, &'static str, &'static str)> {
353 vec![
354 (
356 ".lock().unwrap()",
357 "sync_mutex",
358 "Use tokio::sync::Mutex::lock().await instead",
359 ),
360 (
361 ".lock().expect(",
362 "sync_mutex",
363 "Use tokio::sync::Mutex::lock().await instead",
364 ),
365 (
366 "mutex.lock()",
367 "sync_mutex",
368 "Use tokio::sync::Mutex::lock().await instead",
369 ),
370 (
371 "fs::read_to_string(",
372 "blocking_fs",
373 "Use tokio::fs::read_to_string().await instead",
374 ),
375 (
376 "fs::read(",
377 "blocking_fs",
378 "Use tokio::fs::read().await instead",
379 ),
380 (
381 "fs::write(",
382 "blocking_fs",
383 "Use tokio::fs::write().await instead",
384 ),
385 (
386 "fs::remove_file(",
387 "blocking_fs",
388 "Use tokio::fs::remove_file().await instead",
389 ),
390 (
391 "fs::create_dir(",
392 "blocking_fs",
393 "Use tokio::fs::create_dir().await instead",
394 ),
395 (
396 "fs::create_dir_all(",
397 "blocking_fs",
398 "Use tokio::fs::create_dir_all().await instead",
399 ),
400 (
401 "fs::metadata(",
402 "blocking_fs",
403 "Use tokio::fs::metadata().await instead",
404 ),
405 (
406 "fs::File::open(",
407 "blocking_fs",
408 "Use tokio::fs::File::open().await instead",
409 ),
410 (
411 "fs::File::create(",
412 "blocking_fs",
413 "Use tokio::fs::File::create().await instead",
414 ),
415 (
416 "File::open(",
417 "blocking_fs",
418 "Use tokio::fs::File::open().await instead",
419 ),
420 (
421 "File::create(",
422 "blocking_fs",
423 "Use tokio::fs::File::create().await instead",
424 ),
425 (
426 "TcpStream::connect(",
427 "blocking_net",
428 "Use tokio::net::TcpStream::connect().await instead",
429 ),
430 (
431 "TcpListener::bind(",
432 "blocking_net",
433 "Use tokio::net::TcpListener::bind().await instead",
434 ),
435 (
436 "UdpSocket::bind(",
437 "blocking_net",
438 "Use tokio::net::UdpSocket::bind().await instead",
439 ),
440 (
441 "stdin().read_line(",
442 "blocking_io",
443 "Use tokio::io::stdin() with AsyncBufReadExt instead",
444 ),
445 (
446 "stdin().read(",
447 "blocking_io",
448 "Use tokio::io::stdin() with AsyncReadExt instead",
449 ),
450 (
451 "reqwest::blocking::",
452 "blocking_http",
453 "Use reqwest async API (reqwest::get, Client::new()) instead",
454 ),
455 ]
456 }
457
458 fn safe_wrappers() -> &'static [&'static str] {
460 &[
461 "spawn_blocking",
462 "block_in_place",
463 "tokio::task::spawn_blocking",
464 "tokio::task::block_in_place",
465 "actix_web::web::block",
466 ]
467 }
468}
469
470impl Rule for BlockingOpsInAsyncRule {
471 fn metadata(&self) -> &RuleMetadata {
472 &self.metadata
473 }
474
475 fn evaluate(
476 &self,
477 package: &MirPackage,
478 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
479 ) -> Vec<Finding> {
480 if package.crate_name == "mir-extractor" {
481 return Vec::new();
482 }
483
484 let mut findings = Vec::new();
485 let crate_root = Path::new(&package.crate_root);
486
487 if !crate_root.exists() {
488 return findings;
489 }
490
491 for entry in WalkDir::new(crate_root)
492 .into_iter()
493 .filter_entry(|e| filter_entry(e))
494 {
495 let entry = match entry {
496 Ok(e) => e,
497 Err(_) => continue,
498 };
499
500 if !entry.file_type().is_file() {
501 continue;
502 }
503
504 let path = entry.path();
505 if path.extension() != Some(OsStr::new("rs")) {
506 continue;
507 }
508
509 let rel_path = path
510 .strip_prefix(crate_root)
511 .unwrap_or(path)
512 .to_string_lossy()
513 .replace('\\', "/");
514
515 let content = match fs::read_to_string(path) {
516 Ok(c) => c,
517 Err(_) => continue,
518 };
519
520 let lines: Vec<&str> = content.lines().collect();
521
522 let mut in_async_fn = false;
523 let mut async_fn_start = 0;
524 let mut brace_depth = 0;
525 let mut async_fn_name = String::new();
526 let mut in_safe_wrapper = false;
527 let mut safe_wrapper_depth = 0;
528
529 for (idx, line) in lines.iter().enumerate() {
530 let trimmed = line.trim();
531
532 if trimmed.contains("async fn ") || trimmed.contains("async move") {
533 if trimmed.contains("async fn ") {
534 in_async_fn = true;
535 async_fn_start = idx;
536 brace_depth = 0;
537
538 if let Some(fn_pos) = trimmed.find("fn ") {
539 let after_fn = &trimmed[fn_pos + 3..];
540 if let Some(paren_pos) = after_fn.find('(') {
541 async_fn_name = after_fn[..paren_pos].trim().to_string();
542 }
543 }
544 }
545 }
546
547 if in_async_fn {
548 for wrapper in Self::safe_wrappers() {
549 if trimmed.contains(wrapper) {
550 in_safe_wrapper = true;
551 safe_wrapper_depth = brace_depth;
552 }
553 }
554
555 brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
556 brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
557
558 if in_safe_wrapper && brace_depth <= safe_wrapper_depth {
559 in_safe_wrapper = false;
560 }
561
562 if in_safe_wrapper {
563 if brace_depth <= 0 && idx > async_fn_start {
564 in_async_fn = false;
565 }
566 continue;
567 }
568
569 for (pattern, category, recommendation) in Self::blocking_patterns() {
570 if trimmed.contains(pattern) {
571 if trimmed.contains(".await") || trimmed.contains("tokio::") {
572 continue;
573 }
574 if trimmed.starts_with("//")
575 || trimmed.starts_with("*")
576 || trimmed.starts_with("/*")
577 {
578 continue;
579 }
580
581 let fn_content: String = lines[async_fn_start..=idx]
582 .iter()
583 .copied()
584 .collect::<Vec<&str>>()
585 .join("\n");
586 if fn_content.contains("tokio::sync::Mutex")
587 && pattern.contains(".lock")
588 {
589 continue;
590 }
591
592 let location = format!("{}:{}", rel_path, idx + 1);
593 let message = match category {
594 "sync_mutex" => format!(
595 "Blocking std::sync::Mutex in async function `{}`. {}",
596 async_fn_name, recommendation
597 ),
598 "blocking_fs" => format!(
599 "Blocking filesystem operation in async function `{}`. {}",
600 async_fn_name, recommendation
601 ),
602 "blocking_net" => format!(
603 "Blocking network operation in async function `{}`. {}",
604 async_fn_name, recommendation
605 ),
606 "blocking_io" => format!(
607 "Blocking I/O in async function `{}`. {}",
608 async_fn_name, recommendation
609 ),
610 "blocking_http" => format!(
611 "Blocking HTTP client in async function `{}`. {}",
612 async_fn_name, recommendation
613 ),
614 _ => format!(
615 "Blocking operation in async function `{}`",
616 async_fn_name
617 ),
618 };
619
620 findings.push(Finding {
621 rule_id: self.metadata.id.clone(),
622 rule_name: self.metadata.name.clone(),
623 severity: self.metadata.default_severity,
624 message,
625 function: location,
626 function_signature: async_fn_name.clone(),
627 evidence: vec![trimmed.to_string()],
628 span: None,
629 ..Default::default()
630 });
631 }
632 }
633
634 if brace_depth <= 0 && idx > async_fn_start {
635 in_async_fn = false;
636 }
637 }
638 }
639 }
640
641 findings
642 }
643}
644
645pub struct MutexGuardAcrossAwaitRule {
651 metadata: RuleMetadata,
652}
653
654impl MutexGuardAcrossAwaitRule {
655 pub fn new() -> Self {
656 Self {
657 metadata: RuleMetadata {
658 id: "RUSTCOLA094".to_string(),
659 name: "mutex-guard-across-await".to_string(),
660 short_description: "MutexGuard held across await point".to_string(),
661 full_description: "Holding a std::sync::MutexGuard or RwLockGuard across an .await point can cause deadlocks. \
662 When the async task yields, another task on the same thread may try to acquire the same lock, \
663 leading to deadlock. Use tokio::sync::Mutex or drop the guard before awaiting.".to_string(),
664 help_uri: Some("https://rust-lang.github.io/rust-clippy/master/index.html#await_holding_lock".to_string()),
665 default_severity: Severity::High,
666 origin: RuleOrigin::BuiltIn,
667 cwe_ids: Vec::new(),
668 fix_suggestion: None,
669 exploitability: Exploitability::default(),
670 },
671 }
672 }
673
674 fn guard_patterns() -> &'static [(&'static str, &'static str)] {
675 &[
676 (".lock().unwrap()", "MutexGuard"),
677 (".lock().expect(", "MutexGuard"),
678 (".lock()?", "MutexGuard"),
679 (".read().unwrap()", "RwLockReadGuard"),
680 (".read().expect(", "RwLockReadGuard"),
681 (".read()?", "RwLockReadGuard"),
682 (".write().unwrap()", "RwLockWriteGuard"),
683 (".write().expect(", "RwLockWriteGuard"),
684 (".write()?", "RwLockWriteGuard"),
685 ]
686 }
687
688 fn safe_guard_patterns() -> &'static [&'static str] {
689 &[
690 "tokio::sync::Mutex",
691 "tokio::sync::RwLock",
692 "async_std::sync::Mutex",
693 "async_std::sync::RwLock",
694 "futures::lock::Mutex",
695 ]
696 }
697}
698
699impl Rule for MutexGuardAcrossAwaitRule {
700 fn metadata(&self) -> &RuleMetadata {
701 &self.metadata
702 }
703
704 fn evaluate(
705 &self,
706 package: &MirPackage,
707 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
708 ) -> Vec<Finding> {
709 if package.crate_name == "mir-extractor" {
710 return Vec::new();
711 }
712
713 let mut findings = Vec::new();
714 let crate_root = Path::new(&package.crate_root);
715
716 if !crate_root.exists() {
717 return findings;
718 }
719
720 for entry in WalkDir::new(crate_root)
721 .into_iter()
722 .filter_entry(|e| filter_entry(e))
723 {
724 let entry = match entry {
725 Ok(e) => e,
726 Err(_) => continue,
727 };
728
729 if !entry.file_type().is_file() {
730 continue;
731 }
732
733 let path = entry.path();
734 if path.extension() != Some(OsStr::new("rs")) {
735 continue;
736 }
737
738 let rel_path = path
739 .strip_prefix(crate_root)
740 .unwrap_or(path)
741 .to_string_lossy()
742 .replace('\\', "/");
743
744 let content = match fs::read_to_string(path) {
745 Ok(c) => c,
746 Err(_) => continue,
747 };
748
749 let lines: Vec<&str> = content.lines().collect();
750
751 let mut in_async_fn = false;
752 let mut async_fn_start = 0;
753 let mut brace_depth = 0;
754 let mut async_fn_name = String::new();
755
756 for (idx, line) in lines.iter().enumerate() {
757 let trimmed = line.trim();
758
759 if trimmed.contains("async fn ") || trimmed.contains("async move") {
760 if trimmed.contains("async fn ") {
761 in_async_fn = true;
762 async_fn_start = idx;
763 brace_depth = 0;
764
765 if let Some(fn_pos) = trimmed.find("fn ") {
766 let after_fn = &trimmed[fn_pos + 3..];
767 if let Some(paren_pos) = after_fn.find('(') {
768 async_fn_name = after_fn[..paren_pos].trim().to_string();
769 }
770 }
771 }
772 }
773
774 if in_async_fn {
775 brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
776 brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
777
778 for (pattern, guard_type) in Self::guard_patterns() {
779 if trimmed.contains(pattern) {
780 if trimmed.contains(".await") {
781 continue;
782 }
783 if trimmed.starts_with("//")
784 || trimmed.starts_with("*")
785 || trimmed.starts_with("/*")
786 {
787 continue;
788 }
789
790 let fn_content: String = lines
791 [async_fn_start..=std::cmp::min(idx + 50, lines.len() - 1)]
792 .iter()
793 .copied()
794 .collect::<Vec<&str>>()
795 .join("\n");
796
797 let uses_async_mutex = Self::safe_guard_patterns()
798 .iter()
799 .any(|p| fn_content.contains(p));
800 if uses_async_mutex {
801 continue;
802 }
803
804 let mut inner_brace_depth = 0;
805 let mut has_await_after = false;
806 let mut await_line = 0;
807
808 for (later_idx, later_line) in lines[idx..].iter().enumerate() {
809 let later_trimmed = later_line.trim();
810 inner_brace_depth +=
811 later_trimmed.chars().filter(|&c| c == '{').count() as i32;
812 inner_brace_depth -=
813 later_trimmed.chars().filter(|&c| c == '}').count() as i32;
814
815 if later_trimmed.contains("drop(") {
816 break;
817 }
818
819 if later_idx > 0 && later_trimmed.contains(".await") {
820 has_await_after = true;
821 await_line = idx + later_idx + 1;
822 break;
823 }
824
825 if inner_brace_depth < 0 {
826 break;
827 }
828
829 if later_idx > 30 {
830 break;
831 }
832 }
833
834 if has_await_after {
835 let location = format!("{}:{}", rel_path, idx + 1);
836 findings.push(Finding {
837 rule_id: self.metadata.id.clone(),
838 rule_name: self.metadata.name.clone(),
839 severity: self.metadata.default_severity,
840 message: format!(
841 "{} held across .await in async function `{}` (line {}). \
842 This can cause deadlocks. Drop the guard before awaiting or use tokio::sync::Mutex.",
843 guard_type, async_fn_name, await_line
844 ),
845 function: location,
846 function_signature: async_fn_name.clone(),
847 evidence: vec![trimmed.to_string()],
848 span: None,
849 ..Default::default()
850 });
851 }
852 }
853 }
854
855 if brace_depth <= 0 && idx > async_fn_start {
856 in_async_fn = false;
857 }
858 }
859 }
860 }
861
862 findings
863 }
864}
865
866pub struct UnderscoreLockGuardRule {
872 metadata: RuleMetadata,
873}
874
875impl UnderscoreLockGuardRule {
876 pub fn new() -> Self {
877 Self {
878 metadata: RuleMetadata {
879 id: "RUSTCOLA030".to_string(),
880 name: "underscore-lock-guard".to_string(),
881 short_description: "Lock guard immediately discarded via underscore binding".to_string(),
882 full_description: "Detects lock guards (Mutex::lock, RwLock::read/write, etc.) assigned to `_`, which immediately drops the guard and releases the lock before the critical section executes, creating race conditions.".to_string(),
883 help_uri: Some("https://rust-lang.github.io/rust-clippy/master/index.html#/let_underscore_lock".to_string()),
884 default_severity: Severity::High,
885 origin: RuleOrigin::BuiltIn,
886 cwe_ids: Vec::new(),
887 fix_suggestion: None,
888 exploitability: Exploitability::default(),
889 },
890 }
891 }
892
893 fn lock_method_patterns() -> &'static [&'static str] {
894 &[
895 "::lock(",
896 "::read(",
897 "::write(",
898 "::try_lock(",
899 "::try_read(",
900 "::try_write(",
901 "::blocking_lock(",
902 "::blocking_read(",
903 "::blocking_write(",
904 ]
905 }
906}
907
908impl Rule for UnderscoreLockGuardRule {
909 fn metadata(&self) -> &RuleMetadata {
910 &self.metadata
911 }
912
913 fn evaluate(
914 &self,
915 package: &MirPackage,
916 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
917 ) -> Vec<Finding> {
918 if package.crate_name == "mir-extractor" {
919 return Vec::new();
920 }
921
922 let mut findings = Vec::new();
923
924 for function in &package.functions {
925 let mut named_vars: HashSet<String> = HashSet::new();
929
930 for line in &function.body {
931 let trimmed = line.trim();
932 if trimmed.starts_with("debug ") && trimmed.contains(" => ") {
934 if let Some(arrow_pos) = trimmed.find(" => ") {
936 let var_part = trimmed[arrow_pos + 4..].trim().trim_end_matches(';').trim();
937 if var_part.starts_with('_')
938 && var_part
939 .chars()
940 .nth(1)
941 .map_or(false, |c| c.is_ascii_digit())
942 {
943 named_vars.insert(var_part.to_string());
944 }
945 }
946 }
947 }
948
949 let body_lines: Vec<&str> = function.body.iter().map(|s| s.as_str()).collect();
950
951 for (i, line) in body_lines.iter().enumerate() {
954 let trimmed = line.trim();
955
956 let has_lock_call = Self::lock_method_patterns()
958 .iter()
959 .any(|pattern| trimmed.contains(pattern));
960
961 if !has_lock_call {
962 continue;
963 }
964
965 if !trimmed.contains(" = ") {
967 continue;
968 }
969
970 let lock_result_var = trimmed.split(" = ").next().map(|s| s.trim()).unwrap_or("");
971
972 if !lock_result_var.starts_with('_')
974 || !lock_result_var
975 .chars()
976 .nth(1)
977 .map_or(false, |c| c.is_ascii_digit())
978 {
979 continue;
980 }
981
982 let drop_pattern = format!("drop({})", lock_result_var);
985 let has_direct_drop = body_lines
986 .iter()
987 .skip(i + 1)
988 .take(15)
989 .any(|future_line| future_line.trim().starts_with(&drop_pattern));
990
991 if has_direct_drop && !named_vars.contains(lock_result_var) {
992 findings.push(Finding {
993 rule_id: self.metadata.id.clone(),
994 rule_name: self.metadata.name.clone(),
995 severity: self.metadata.default_severity,
996 message: format!(
997 "Lock guard assigned to `_` in `{}`, immediately releasing the lock",
998 function.name
999 ),
1000 function: function.name.clone(),
1001 function_signature: function.signature.clone(),
1002 evidence: vec![trimmed.to_string()],
1003 span: function.span.clone(),
1004 confidence: Confidence::Medium,
1005 cwe_ids: Vec::new(),
1006 fix_suggestion: None,
1007 code_snippet: None,
1008 exploitability: Exploitability::default(),
1009 exploitability_score: Exploitability::default().score(),
1010 ..Default::default()
1011 });
1012 continue;
1013 }
1014
1015 let unwrap_source_pattern = format!("(move {})", lock_result_var);
1018 for future_line in body_lines.iter().skip(i + 1).take(15) {
1019 let future_trimmed = future_line.trim();
1020
1021 if (future_trimmed.contains("unwrap") || future_trimmed.contains("expect"))
1023 && future_trimmed.contains(&unwrap_source_pattern)
1024 && future_trimmed.contains(" = ")
1025 {
1026 let guard_var = future_trimmed
1027 .split(" = ")
1028 .next()
1029 .map(|s| s.trim())
1030 .unwrap_or("");
1031
1032 if guard_var.starts_with('_')
1034 && guard_var
1035 .chars()
1036 .nth(1)
1037 .map_or(false, |c| c.is_ascii_digit())
1038 && !named_vars.contains(guard_var)
1039 {
1040 let guard_drop_pattern = format!("drop({})", guard_var);
1041 let has_guard_drop = body_lines
1042 .iter()
1043 .any(|line| line.trim().starts_with(&guard_drop_pattern));
1044
1045 if has_guard_drop {
1046 findings.push(Finding {
1047 rule_id: self.metadata.id.clone(),
1048 rule_name: self.metadata.name.clone(),
1049 severity: self.metadata.default_severity,
1050 message: format!(
1051 "Lock guard assigned to `_` in `{}`, immediately releasing the lock",
1052 function.name
1053 ),
1054 function: function.name.clone(),
1055 function_signature: function.signature.clone(),
1056 evidence: vec![trimmed.to_string(), future_trimmed.to_string()],
1057 span: function.span.clone(),
1058 confidence: Confidence::Medium,
1059 cwe_ids: Vec::new(),
1060 fix_suggestion: None,
1061 code_snippet: None,
1062 exploitability: Exploitability::default(),
1063 exploitability_score: Exploitability::default().score(),
1064 ..Default::default()
1065 });
1066 break;
1067 }
1068 }
1069 }
1070 }
1071 }
1072 }
1073
1074 findings
1075 }
1076}
1077
1078pub struct BroadcastUnsyncPayloadRule {
1084 metadata: RuleMetadata,
1085}
1086
1087impl BroadcastUnsyncPayloadRule {
1088 pub fn new() -> Self {
1089 Self {
1090 metadata: RuleMetadata {
1091 id: "RUSTCOLA023".to_string(),
1092 name: "tokio-broadcast-unsync-payload".to_string(),
1093 short_description: "Tokio broadcast carries !Sync payload".to_string(),
1094 full_description: "Warns when `tokio::sync::broadcast` channels are instantiated for types like `Rc`/`RefCell` that are not Sync, enabling unsound clones to cross thread boundaries. See RUSTSEC-2025-0023 for details.".to_string(),
1095 help_uri: Some("https://rustsec.org/advisories/RUSTSEC-2025-0023.html".to_string()),
1096 default_severity: Severity::High,
1097 origin: RuleOrigin::BuiltIn,
1098 cwe_ids: Vec::new(),
1099 fix_suggestion: None,
1100 exploitability: Exploitability::default(),
1101 },
1102 }
1103 }
1104}
1105
1106impl Rule for BroadcastUnsyncPayloadRule {
1107 fn metadata(&self) -> &RuleMetadata {
1108 &self.metadata
1109 }
1110
1111 fn evaluate(
1112 &self,
1113 package: &MirPackage,
1114 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1115 ) -> Vec<Finding> {
1116 let mut findings = Vec::new();
1117
1118 for function in &package.functions {
1119 let usages = detect_broadcast_unsync_payloads(function);
1120
1121 for usage in usages {
1122 findings.push(Finding {
1123 rule_id: self.metadata.id.clone(),
1124 rule_name: self.metadata.name.clone(),
1125 severity: self.metadata.default_severity,
1126 message: format!(
1127 "Broadcast channel instantiated with !Sync payload in `{}`",
1128 function.name
1129 ),
1130 function: function.name.clone(),
1131 function_signature: function.signature.clone(),
1132 evidence: vec![usage.line.clone()],
1133 span: function.span.clone(),
1134 confidence: Confidence::Medium,
1135 cwe_ids: Vec::new(),
1136 fix_suggestion: None,
1137 code_snippet: None,
1138 exploitability: Exploitability::default(),
1139 exploitability_score: Exploitability::default().score(),
1140 ..Default::default()
1141 });
1142 }
1143 }
1144
1145 findings
1146 }
1147}
1148
1149pub struct PanicInDropRule {
1155 metadata: RuleMetadata,
1156}
1157
1158impl PanicInDropRule {
1159 pub fn new() -> Self {
1160 Self {
1161 metadata: RuleMetadata {
1162 id: "RUSTCOLA040".to_string(),
1163 name: "panic-in-drop".to_string(),
1164 short_description: "panic! or unwrap in Drop implementation".to_string(),
1165 full_description: "Detects panic!, unwrap(), or expect() calls inside Drop trait implementations. Panicking during stack unwinding causes the process to abort, which can mask the original error and make debugging difficult. Drop implementations should handle errors gracefully or use logging instead of panicking.".to_string(),
1166 help_uri: Some("https://doc.rust-lang.org/nomicon/exception-safety.html".to_string()),
1167 default_severity: Severity::Medium,
1168 origin: RuleOrigin::BuiltIn,
1169 cwe_ids: Vec::new(),
1170 fix_suggestion: None,
1171 exploitability: Exploitability::default(),
1172 },
1173 }
1174 }
1175
1176 fn panic_patterns() -> &'static [&'static str] {
1177 &[
1178 "panic!",
1179 ".unwrap()",
1180 ".expect(",
1181 "unreachable!",
1182 "unimplemented!",
1183 "todo!",
1184 ]
1185 }
1186}
1187
1188impl Rule for PanicInDropRule {
1189 fn metadata(&self) -> &RuleMetadata {
1190 &self.metadata
1191 }
1192
1193 fn evaluate(
1194 &self,
1195 package: &MirPackage,
1196 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1197 ) -> Vec<Finding> {
1198 if package.crate_name == "mir-extractor" {
1199 return Vec::new();
1200 }
1201
1202 let mut findings = Vec::new();
1203 let crate_root = Path::new(&package.crate_root);
1204
1205 if !crate_root.exists() {
1206 return findings;
1207 }
1208
1209 for entry in WalkDir::new(crate_root)
1210 .into_iter()
1211 .filter_entry(|e| filter_entry(e))
1212 {
1213 let entry = match entry {
1214 Ok(e) => e,
1215 Err(_) => continue,
1216 };
1217
1218 if !entry.file_type().is_file() {
1219 continue;
1220 }
1221
1222 let path = entry.path();
1223 if path.extension() != Some(OsStr::new("rs")) {
1224 continue;
1225 }
1226
1227 let rel_path = path
1228 .strip_prefix(crate_root)
1229 .unwrap_or(path)
1230 .to_string_lossy()
1231 .replace('\\', "/");
1232
1233 let content = match fs::read_to_string(path) {
1234 Ok(c) => c,
1235 Err(_) => continue,
1236 };
1237
1238 let lines: Vec<&str> = content.lines().collect();
1239
1240 let mut in_drop_impl = false;
1242 let mut drop_impl_start = 0;
1243 let mut brace_depth = 0;
1244 let mut drop_type_name = String::new();
1245
1246 for (idx, line) in lines.iter().enumerate() {
1247 let trimmed = line.trim();
1248
1249 if trimmed.contains("impl") && trimmed.contains("Drop") && trimmed.contains("for") {
1251 in_drop_impl = true;
1252 drop_impl_start = idx;
1253 brace_depth = 0;
1254
1255 if let Some(for_pos) = trimmed.find("for ") {
1257 let after_for = &trimmed[for_pos + 4..];
1258 if let Some(space_pos) =
1259 after_for.find(|c: char| c.is_whitespace() || c == '{')
1260 {
1261 drop_type_name = after_for[..space_pos].trim().to_string();
1262 } else {
1263 drop_type_name = after_for.trim().to_string();
1264 }
1265 }
1266 }
1267
1268 if in_drop_impl {
1269 brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
1270 brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
1271
1272 for pattern in Self::panic_patterns() {
1274 if trimmed.contains(pattern) {
1275 if !trimmed.starts_with("//") {
1277 let location = format!("{}:{}", rel_path, idx + 1);
1278
1279 findings.push(Finding {
1280 rule_id: self.metadata.id.clone(),
1281 rule_name: self.metadata.name.clone(),
1282 severity: self.metadata.default_severity,
1283 message: format!(
1284 "Panic in Drop implementation for `{}` can cause abort during unwinding",
1285 drop_type_name
1286 ),
1287 function: location,
1288 function_signature: drop_type_name.clone(),
1289 evidence: vec![trimmed.to_string()],
1290 span: None,
1291 ..Default::default()
1292 });
1293 }
1294 }
1295 }
1296
1297 if brace_depth <= 0 && idx > drop_impl_start {
1299 in_drop_impl = false;
1300 }
1301 }
1302 }
1303 }
1304
1305 findings
1306 }
1307}
1308
1309pub struct UnwrapInPollRule {
1315 metadata: RuleMetadata,
1316}
1317
1318impl UnwrapInPollRule {
1319 pub fn new() -> Self {
1320 Self {
1321 metadata: RuleMetadata {
1322 id: "RUSTCOLA041".to_string(),
1323 name: "unwrap-in-poll".to_string(),
1324 short_description: "unwrap or panic in Future::poll implementation".to_string(),
1325 full_description: "Detects unwrap(), expect(), or panic! calls inside Future::poll implementations. Panicking in poll() can stall async executors, cause runtime hangs, and make debugging async code difficult. Poll implementations should propagate errors using Poll::Ready(Err(...)) or use defensive patterns like match/if-let.".to_string(),
1326 help_uri: Some("https://rust-lang.github.io/async-book/02_execution/03_wakeups.html".to_string()),
1327 default_severity: Severity::Medium,
1328 origin: RuleOrigin::BuiltIn,
1329 cwe_ids: Vec::new(),
1330 fix_suggestion: None,
1331 exploitability: Exploitability::default(),
1332 },
1333 }
1334 }
1335
1336 fn panic_patterns() -> &'static [&'static str] {
1337 &[
1338 "panic!",
1339 ".unwrap()",
1340 ".expect(",
1341 "unreachable!",
1342 "unimplemented!",
1343 "todo!",
1344 ]
1345 }
1346}
1347
1348impl Rule for UnwrapInPollRule {
1349 fn metadata(&self) -> &RuleMetadata {
1350 &self.metadata
1351 }
1352
1353 fn evaluate(
1354 &self,
1355 package: &MirPackage,
1356 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1357 ) -> Vec<Finding> {
1358 if package.crate_name == "mir-extractor" {
1359 return Vec::new();
1360 }
1361
1362 let mut findings = Vec::new();
1363 let crate_root = Path::new(&package.crate_root);
1364
1365 if !crate_root.exists() {
1366 return findings;
1367 }
1368
1369 for entry in WalkDir::new(crate_root)
1370 .into_iter()
1371 .filter_entry(|e| filter_entry(e))
1372 {
1373 let entry = match entry {
1374 Ok(e) => e,
1375 Err(_) => continue,
1376 };
1377
1378 if !entry.file_type().is_file() {
1379 continue;
1380 }
1381
1382 let path = entry.path();
1383 if path.extension() != Some(OsStr::new("rs")) {
1384 continue;
1385 }
1386
1387 let rel_path = path
1388 .strip_prefix(crate_root)
1389 .unwrap_or(path)
1390 .to_string_lossy()
1391 .replace('\\', "/");
1392
1393 let content = match fs::read_to_string(path) {
1394 Ok(c) => c,
1395 Err(_) => continue,
1396 };
1397
1398 let lines: Vec<&str> = content.lines().collect();
1399
1400 let mut in_future_impl = false;
1402 let mut in_poll_method = false;
1403 let mut poll_start = 0;
1404 let mut brace_depth = 0;
1405 let mut impl_brace_depth = 0;
1406 let mut future_type_name = String::new();
1407
1408 for (idx, line) in lines.iter().enumerate() {
1409 let trimmed = line.trim();
1410
1411 if !in_future_impl
1413 && trimmed.contains("impl")
1414 && trimmed.contains("Future")
1415 && trimmed.contains("for")
1416 {
1417 in_future_impl = true;
1418 impl_brace_depth = 0;
1419
1420 if let Some(for_pos) = trimmed.find("for ") {
1422 let after_for = &trimmed[for_pos + 4..];
1423 if let Some(space_pos) =
1424 after_for.find(|c: char| c.is_whitespace() || c == '{')
1425 {
1426 future_type_name = after_for[..space_pos].trim().to_string();
1427 } else {
1428 future_type_name = after_for.trim().to_string();
1429 }
1430 }
1431 }
1432
1433 if in_future_impl {
1434 impl_brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
1435 impl_brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
1436
1437 if !in_poll_method
1439 && (trimmed.contains("fn poll") || trimmed.contains("fn poll("))
1440 {
1441 in_poll_method = true;
1442 poll_start = idx;
1443 brace_depth = 0;
1444 }
1445
1446 if in_poll_method {
1447 brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
1448 brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
1449
1450 for pattern in Self::panic_patterns() {
1452 if trimmed.contains(pattern) {
1453 if !trimmed.starts_with("//") {
1455 let location = format!("{}:{}", rel_path, idx + 1);
1456
1457 findings.push(Finding {
1458 rule_id: self.metadata.id.clone(),
1459 rule_name: self.metadata.name.clone(),
1460 severity: self.metadata.default_severity,
1461 message: format!(
1462 "Panic in Future::poll for `{}` can stall async executor",
1463 future_type_name
1464 ),
1465 function: location,
1466 function_signature: future_type_name.clone(),
1467 evidence: vec![trimmed.to_string()],
1468 span: None,
1469 ..Default::default()
1470 });
1471 }
1472 }
1473 }
1474
1475 if brace_depth <= 0 && idx > poll_start {
1477 in_poll_method = false;
1478 }
1479 }
1480
1481 if impl_brace_depth <= 0 && idx > 0 {
1483 in_future_impl = false;
1484 }
1485 }
1486 }
1487 }
1488
1489 findings
1490 }
1491}
1492
1493pub struct UnsafeSendSyncBoundsRule {
1500 metadata: RuleMetadata,
1501}
1502
1503impl UnsafeSendSyncBoundsRule {
1504 pub fn new() -> Self {
1505 Self {
1506 metadata: RuleMetadata {
1507 id: "RUSTCOLA015".to_string(),
1508 name: "unsafe-send-sync-bounds".to_string(),
1509 short_description: "Unsafe Send/Sync impl without generic bounds".to_string(),
1510 full_description: "Detects unsafe implementations of Send/Sync for generic types that do not constrain their generic parameters, which can reintroduce thread-safety bugs.".to_string(),
1511 help_uri: None,
1512 default_severity: Severity::High,
1513 origin: RuleOrigin::BuiltIn,
1514 cwe_ids: Vec::new(),
1515 fix_suggestion: None,
1516 exploitability: Exploitability::default(),
1517 },
1518 }
1519 }
1520
1521 fn has_required_bounds(block_text: &str, trait_name: &str) -> bool {
1522 let trait_marker = format!(" {trait_name} for");
1523 let Some(for_idx) = block_text.find(&trait_marker) else {
1524 return true;
1525 };
1526 let before_for = &block_text[..for_idx];
1527 let Some((generics_text, generic_names)) = Self::extract_generic_params(before_for) else {
1528 return true;
1529 };
1530
1531 if generic_names.is_empty() {
1532 return true;
1533 }
1534
1535 let generic_set: HashSet<String> = generic_names.iter().cloned().collect();
1536 let mut satisfied: HashSet<String> = HashSet::new();
1537
1538 for (name, bounds) in Self::parse_inline_bounds(&generics_text) {
1539 if !generic_set.contains(&name) {
1540 continue;
1541 }
1542
1543 if bounds
1544 .iter()
1545 .any(|bound| Self::bound_matches_trait(bound, trait_name))
1546 {
1547 satisfied.insert(name.clone());
1548 }
1549 }
1550
1551 if let Some(where_clauses) = Self::extract_where_clauses(block_text) {
1552 for (name, bounds) in where_clauses {
1553 if !generic_set.contains(&name) {
1554 continue;
1555 }
1556
1557 if bounds
1558 .iter()
1559 .any(|bound| Self::bound_matches_trait(bound, trait_name))
1560 {
1561 satisfied.insert(name);
1562 }
1563 }
1564 }
1565
1566 generic_names
1567 .into_iter()
1568 .all(|name| satisfied.contains(&name))
1569 }
1570
1571 fn extract_generic_params(before_for: &str) -> Option<(String, Vec<String>)> {
1572 let start = before_for.find('<')?;
1573 let end_offset = before_for[start..].find('>')?;
1574 let generics_text = before_for[start + 1..start + end_offset].trim().to_string();
1575
1576 let mut names = Vec::new();
1577 for param in generics_text.split(',') {
1578 if let Some(name) = Self::normalize_generic_name(param) {
1579 names.push(name);
1580 }
1581 }
1582
1583 Some((generics_text, names))
1584 }
1585
1586 fn parse_inline_bounds(generics_text: &str) -> Vec<(String, Vec<String>)> {
1587 generics_text
1588 .split(',')
1589 .filter_map(|param| {
1590 let Some(name) = Self::normalize_generic_name(param) else {
1591 return None;
1592 };
1593
1594 let trimmed = param.trim();
1595 let mut parts = trimmed.splitn(2, ':');
1596 parts.next()?;
1597 let bounds = parts
1598 .next()
1599 .map(|rest| Self::split_bounds(rest))
1600 .unwrap_or_default();
1601
1602 Some((name, bounds))
1603 })
1604 .collect()
1605 }
1606
1607 fn normalize_generic_name(token: &str) -> Option<String> {
1608 let token = token.trim();
1609 if token.is_empty() {
1610 return None;
1611 }
1612
1613 if token.starts_with("const ") {
1614 return None;
1615 }
1616
1617 if token.starts_with('\'') {
1618 return None;
1619 }
1620
1621 let ident = token
1622 .split(|c: char| c == ':' || c == '=' || c.is_whitespace())
1623 .next()
1624 .unwrap_or("")
1625 .trim();
1626
1627 if ident.is_empty() {
1628 None
1629 } else {
1630 Some(ident.to_string())
1631 }
1632 }
1633
1634 fn extract_where_clauses(block_text: &str) -> Option<Vec<(String, Vec<String>)>> {
1635 let where_idx = block_text.find(" where ")?;
1636 let after_where = &block_text[where_idx + " where ".len()..];
1637 let end_idx = after_where
1638 .find('{')
1639 .or_else(|| after_where.find(';'))
1640 .unwrap_or(after_where.len());
1641 let clauses = after_where[..end_idx].trim();
1642 if clauses.is_empty() {
1643 return Some(Vec::new());
1644 }
1645
1646 let mut result = Vec::new();
1647 for predicate in clauses.split(',') {
1648 let pred = predicate.trim();
1649 if pred.is_empty() {
1650 continue;
1651 }
1652
1653 let mut parts = pred.splitn(2, ':');
1654 let ident = parts.next().unwrap_or("").trim();
1655 if ident.is_empty() {
1656 continue;
1657 }
1658
1659 let bounds = parts
1660 .next()
1661 .map(|rest| Self::split_bounds(rest))
1662 .unwrap_or_default();
1663 result.push((ident.to_string(), bounds));
1664 }
1665
1666 Some(result)
1667 }
1668
1669 fn split_bounds(bounds: &str) -> Vec<String> {
1670 bounds
1671 .split('+')
1672 .map(|part| {
1673 part.trim()
1674 .trim_start_matches('?')
1675 .trim_start_matches("~const ")
1676 .trim_end_matches(|c| matches!(c, ',' | '{' | ';'))
1677 .to_string()
1678 })
1679 .filter(|part| !part.is_empty())
1680 .collect()
1681 }
1682
1683 fn scan_string_state(
1684 state: StringLiteralState,
1685 line: &str,
1686 ) -> (bool, String, StringLiteralState) {
1687 let (sanitized, state_after) = strip_string_literals(state, line);
1688 let has_impl = sanitized.contains("unsafe impl")
1689 && (sanitized.contains(" Send for") || sanitized.contains(" Sync for"));
1690 (has_impl, sanitized, state_after)
1691 }
1692
1693 fn bound_matches_trait(bound: &str, trait_name: &str) -> bool {
1694 let normalized = bound.trim();
1695 if normalized.is_empty() {
1696 return false;
1697 }
1698
1699 let normalized = normalized
1700 .trim_start_matches("dyn ")
1701 .trim_start_matches("impl ");
1702
1703 if normalized == trait_name {
1704 return true;
1705 }
1706
1707 if normalized.ends_with(trait_name)
1708 && normalized
1709 .trim_end_matches(trait_name)
1710 .trim_end()
1711 .ends_with("::")
1712 {
1713 return true;
1714 }
1715
1716 if let Some(start) = normalized.find('<') {
1717 let (path, generics) = normalized.split_at(start);
1718 if Self::bound_matches_trait(path.trim_end_matches('<'), trait_name) {
1719 return generics
1720 .trim_matches(|c| c == '<' || c == '>')
1721 .split(',')
1722 .any(|part| Self::bound_matches_trait(part, trait_name));
1723 }
1724 }
1725
1726 if let Some(idx) = normalized.find('<') {
1727 let inner = normalized[idx + 1..].trim_end_matches('>').trim();
1728 if inner.starts_with("*const") || inner.starts_with("*mut") || inner.starts_with('&') {
1729 return true;
1730 }
1731 }
1732
1733 let tokens: Vec<_> = normalized
1734 .split(|c: char| c == ':' || c == '+' || c == ',' || c.is_whitespace())
1735 .filter(|token| !token.is_empty())
1736 .collect();
1737
1738 if tokens.iter().any(|token| token == &trait_name) {
1739 return true;
1740 }
1741
1742 if trait_name == "Send"
1743 && tokens
1744 .iter()
1745 .any(|token| *token == "Sync" || token.ends_with("::Sync"))
1746 {
1747 return true;
1748 }
1749
1750 if trait_name == "Sync"
1751 && tokens
1752 .iter()
1753 .any(|token| *token == "Send" || token.ends_with("::Send"))
1754 {
1755 return true;
1756 }
1757
1758 false
1759 }
1760}
1761
1762impl Rule for UnsafeSendSyncBoundsRule {
1763 fn metadata(&self) -> &RuleMetadata {
1764 &self.metadata
1765 }
1766
1767 fn evaluate(
1768 &self,
1769 package: &MirPackage,
1770 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1771 ) -> Vec<Finding> {
1772 let mut findings = Vec::new();
1773 let crate_root = Path::new(&package.crate_root);
1774
1775 if !crate_root.exists() {
1776 return findings;
1777 }
1778
1779 for entry in WalkDir::new(crate_root)
1780 .into_iter()
1781 .filter_entry(|e| filter_entry(e))
1782 {
1783 let entry = match entry {
1784 Ok(e) => e,
1785 Err(_) => continue,
1786 };
1787
1788 if !entry.file_type().is_file() {
1789 continue;
1790 }
1791
1792 if entry.path().extension().and_then(OsStr::to_str) != Some("rs") {
1793 continue;
1794 }
1795
1796 let Ok(source) = fs::read_to_string(entry.path()) else {
1797 continue;
1798 };
1799
1800 let rel_path = entry
1801 .path()
1802 .strip_prefix(crate_root)
1803 .unwrap_or_else(|_| entry.path())
1804 .to_string_lossy()
1805 .replace('\\', "/");
1806
1807 let lines: Vec<&str> = source.lines().collect();
1808 let mut idx = 0usize;
1809 let mut string_state = StringLiteralState::default();
1810
1811 while idx < lines.len() {
1812 let line = lines[idx];
1813 let (has_impl, sanitized_line, mut state_after_line) =
1814 Self::scan_string_state(string_state, line);
1815 let trimmed_sanitized = sanitized_line.trim();
1816
1817 if !has_impl {
1818 string_state = state_after_line;
1819 idx += 1;
1820 continue;
1821 }
1822
1823 let mut block_lines = Vec::new();
1824 let trimmed_first = line.trim();
1825 if !trimmed_first.is_empty() {
1826 block_lines.push(trimmed_first.to_string());
1827 }
1828
1829 let mut block_complete =
1830 trimmed_sanitized.contains('{') || trimmed_sanitized.ends_with(';');
1831
1832 let mut j = idx;
1833 while !block_complete && j + 1 < lines.len() {
1834 let next_line = lines[j + 1];
1835 let (next_has_impl, next_sanitized, next_state) =
1836 Self::scan_string_state(state_after_line, next_line);
1837 let trimmed_original = next_line.trim();
1838 let trimmed_sanitized_next = next_sanitized.trim();
1839 let mut appended = false;
1840
1841 if !trimmed_original.is_empty() {
1842 block_lines.push(trimmed_original.to_string());
1843 appended = true;
1844 }
1845
1846 state_after_line = next_state;
1847 block_complete = trimmed_sanitized_next.contains('{')
1848 || trimmed_sanitized_next.ends_with(';');
1849
1850 if next_has_impl {
1851 if appended {
1852 block_lines.pop();
1853 }
1854 break;
1855 }
1856
1857 j += 1;
1858 }
1859
1860 let block_text = block_lines.join(" ");
1861 let trait_name = if block_text.contains(" Send for") {
1862 "Send"
1863 } else if block_text.contains(" Sync for") {
1864 "Sync"
1865 } else {
1866 string_state = state_after_line;
1867 idx = j + 1;
1868 continue;
1869 };
1870
1871 if !Self::has_required_bounds(&block_text, trait_name) {
1872 let location = format!("{}:{}", rel_path, idx + 1);
1873 findings.push(Finding::new(
1874 self.metadata.id.clone(),
1875 self.metadata.name.clone(),
1876 self.metadata.default_severity,
1877 format!("Unsafe impl of {trait_name} without generic bounds"),
1878 location,
1879 block_lines
1880 .first()
1881 .cloned()
1882 .unwrap_or_else(|| trait_name.to_string()),
1883 block_lines.clone(),
1884 None,
1885 ));
1886 }
1887
1888 string_state = state_after_line;
1889 idx = j + 1;
1890 }
1891 }
1892
1893 findings
1894 }
1895}
1896
1897pub struct NonCancellationSafeSelectRule {
1907 metadata: RuleMetadata,
1908}
1909
1910impl NonCancellationSafeSelectRule {
1911 pub fn new() -> Self {
1912 Self {
1913 metadata: RuleMetadata {
1914 id: "RUSTCOLA115".to_string(),
1915 name: "non-cancellation-safe-select".to_string(),
1916 short_description: "Potentially non-cancellation-safe future in select!".to_string(),
1917 full_description: "Detects use of potentially non-cancellation-safe futures in select! macro. \
1918 When using tokio::select!, futures::select!, or similar macros, if a branch is not chosen, \
1919 its future is dropped. If the future has made partial progress (e.g., started reading from \
1920 a buffered stream), that progress is lost. Common non-cancellation-safe patterns include: \
1921 - read_line() / read_until() on buffered streams \
1922 - recv_many() on channels \
1923 - Futures that modify internal state before awaiting \
1924 Consider using cancellation-safe alternatives or restructuring the code.".to_string(),
1925 help_uri: Some("https://docs.rs/tokio/latest/tokio/macro.select.html#cancellation-safety".to_string()),
1926 default_severity: Severity::Medium,
1927 origin: RuleOrigin::BuiltIn,
1928 cwe_ids: Vec::new(),
1929 fix_suggestion: None,
1930 exploitability: Exploitability::default(),
1931 },
1932 }
1933 }
1934
1935 fn non_cancellation_safe_patterns() -> &'static [(&'static str, &'static str)] {
1937 &[
1938 (
1939 "read_line(",
1940 "BufRead::read_line is not cancellation safe - partial reads are lost",
1941 ),
1942 (
1943 "read_until(",
1944 "BufRead::read_until is not cancellation safe - partial reads are lost",
1945 ),
1946 (
1947 "read_exact(",
1948 "read_exact is not cancellation safe - partial reads are lost",
1949 ),
1950 (
1951 "recv_many(",
1952 "recv_many is not cancellation safe - some messages may be lost",
1953 ),
1954 (
1955 "read_to_end(",
1956 "read_to_end is not cancellation safe - partial reads are lost",
1957 ),
1958 (
1959 "read_to_string(",
1960 "read_to_string is not cancellation safe - partial reads are lost",
1961 ),
1962 (
1963 "copy(",
1964 "io::copy is not cancellation safe - partial copy progress is lost",
1965 ),
1966 (
1967 "copy_buf(",
1968 "io::copy_buf is not cancellation safe - partial copy progress is lost",
1969 ),
1970 ]
1971 }
1972
1973 fn select_macro_patterns() -> &'static [&'static str] {
1975 &[
1976 "select!",
1977 "tokio::select!",
1978 "futures::select!",
1979 "futures_util::select!",
1980 ]
1981 }
1982}
1983
1984impl Rule for NonCancellationSafeSelectRule {
1985 fn metadata(&self) -> &RuleMetadata {
1986 &self.metadata
1987 }
1988
1989 fn evaluate(
1990 &self,
1991 package: &MirPackage,
1992 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
1993 ) -> Vec<Finding> {
1994 if package.crate_name == "mir-extractor" {
1995 return Vec::new();
1996 }
1997
1998 let mut findings = Vec::new();
1999 let crate_root = Path::new(&package.crate_root);
2000
2001 if !crate_root.exists() {
2002 return findings;
2003 }
2004
2005 for entry in WalkDir::new(crate_root)
2006 .into_iter()
2007 .filter_entry(|e| filter_entry(e))
2008 {
2009 let entry = match entry {
2010 Ok(e) => e,
2011 Err(_) => continue,
2012 };
2013
2014 if !entry.file_type().is_file() {
2015 continue;
2016 }
2017
2018 let path = entry.path();
2019 if path.extension() != Some(OsStr::new("rs")) {
2020 continue;
2021 }
2022
2023 let rel_path = path
2024 .strip_prefix(crate_root)
2025 .unwrap_or(path)
2026 .to_string_lossy()
2027 .replace('\\', "/");
2028
2029 let content = match fs::read_to_string(path) {
2030 Ok(c) => c,
2031 Err(_) => continue,
2032 };
2033
2034 let lines: Vec<&str> = content.lines().collect();
2035
2036 let mut in_select = false;
2038 let mut select_start_line = 0;
2039 let mut brace_depth = 0;
2040
2041 for (idx, line) in lines.iter().enumerate() {
2042 let trimmed = line.trim();
2043
2044 if trimmed.starts_with("//") {
2046 continue;
2047 }
2048
2049 for pattern in Self::select_macro_patterns() {
2051 if trimmed.contains(pattern) && !in_select {
2052 in_select = true;
2053 select_start_line = idx;
2054 brace_depth = 0;
2055 }
2056 }
2057
2058 if in_select {
2059 brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
2060 brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
2061
2062 for (pattern, reason) in Self::non_cancellation_safe_patterns() {
2064 if trimmed.contains(pattern) {
2065 let location = format!("{}:{}", rel_path, idx + 1);
2066
2067 findings.push(Finding {
2068 rule_id: self.metadata.id.clone(),
2069 rule_name: self.metadata.name.clone(),
2070 severity: self.metadata.default_severity,
2071 message: format!(
2072 "Non-cancellation-safe operation `{}` used in select! macro. {}",
2073 pattern.trim_end_matches('('),
2074 reason
2075 ),
2076 function: location,
2077 function_signature: String::new(),
2078 evidence: vec![trimmed.to_string()],
2079 span: None,
2080 ..Default::default()
2081 });
2082 }
2083 }
2084
2085 if brace_depth <= 0 && idx > select_start_line {
2087 in_select = false;
2088 }
2089 }
2090 }
2091 }
2092
2093 findings
2094 }
2095}
2096
2097pub struct MissingSyncBoundOnCloneRule {
2108 metadata: RuleMetadata,
2109}
2110
2111impl MissingSyncBoundOnCloneRule {
2112 pub fn new() -> Self {
2113 Self {
2114 metadata: RuleMetadata {
2115 id: "RUSTCOLA111".to_string(),
2116 name: "missing-sync-bound-on-clone".to_string(),
2117 short_description: "Clone in concurrent context without Sync bound".to_string(),
2118 full_description: "Detects implementations that clone values in concurrent contexts \
2119 without requiring the `Sync` trait bound. This pattern was found in tokio's \
2120 broadcast channel (RUSTSEC-2025-0023) where concurrent cloning without Sync \
2121 can cause data races. Channels and shared data structures that clone inner \
2122 values should require `T: Clone + Send + Sync` instead of just `T: Clone + Send`.".to_string(),
2123 help_uri: Some("https://rustsec.org/advisories/RUSTSEC-2025-0023.html".to_string()),
2124 default_severity: Severity::High,
2125 origin: RuleOrigin::BuiltIn,
2126 cwe_ids: Vec::new(),
2127 fix_suggestion: None,
2128 exploitability: Exploitability::default(),
2129 },
2130 }
2131 }
2132}
2133
2134impl Rule for MissingSyncBoundOnCloneRule {
2135 fn metadata(&self) -> &RuleMetadata {
2136 &self.metadata
2137 }
2138
2139 fn evaluate(
2140 &self,
2141 package: &MirPackage,
2142 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
2143 ) -> Vec<Finding> {
2144 if package.crate_name == "mir-extractor" {
2145 return Vec::new();
2146 }
2147
2148 let mut findings = Vec::new();
2149 let crate_root = Path::new(&package.crate_root);
2150
2151 if !crate_root.exists() {
2152 return findings;
2153 }
2154
2155 for entry in WalkDir::new(crate_root)
2156 .into_iter()
2157 .filter_entry(|e| filter_entry(e))
2158 {
2159 let entry = match entry {
2160 Ok(e) => e,
2161 Err(_) => continue,
2162 };
2163
2164 if !entry.file_type().is_file() {
2165 continue;
2166 }
2167
2168 let path = entry.path();
2169 if path.extension() != Some(OsStr::new("rs")) {
2170 continue;
2171 }
2172
2173 let rel_path = path
2174 .strip_prefix(crate_root)
2175 .unwrap_or(path)
2176 .to_string_lossy()
2177 .replace('\\', "/");
2178
2179 let content = match fs::read_to_string(path) {
2180 Ok(c) => c,
2181 Err(_) => continue,
2182 };
2183
2184 let lines: Vec<&str> = content.lines().collect();
2185
2186 for (idx, line) in lines.iter().enumerate() {
2189 let trimmed = line.trim();
2190
2191 if trimmed.starts_with("//") {
2193 continue;
2194 }
2195
2196 if trimmed.contains("unsafe impl") && trimmed.contains("Sync") {
2198 let is_channel_like = trimmed.contains("Sender")
2200 || trimmed.contains("Receiver")
2201 || trimmed.contains("Channel")
2202 || trimmed.contains("Broadcast")
2203 || trimmed.contains("Queue")
2204 || trimmed.contains("Buffer");
2205
2206 if is_channel_like {
2207 let context_start = idx.saturating_sub(20);
2209 let context_end = (idx + 20).min(lines.len());
2210
2211 let has_clone_send = lines[context_start..context_end]
2212 .iter()
2213 .any(|l| l.contains("Clone") && l.contains("Send"));
2214 let has_sync_bound = lines[context_start..context_end]
2215 .iter()
2216 .any(|l| l.contains(": Sync") || l.contains("+ Sync"));
2217
2218 if has_clone_send && !has_sync_bound {
2219 let location = format!("{}:{}", rel_path, idx + 1);
2220
2221 findings.push(Finding::new(
2222 self.metadata.id.clone(),
2223 self.metadata.name.clone(),
2224 self.metadata.default_severity,
2225 "unsafe impl Sync for channel-like type with Clone + Send \
2226 but no Sync bound. This may allow data races when cloning. \
2227 Consider adding `T: Sync` bound."
2228 .to_string(),
2229 location,
2230 String::new(),
2231 vec![trimmed.to_string()],
2232 None,
2233 ));
2234 }
2235 }
2236 }
2237
2238 if trimmed.starts_with("impl")
2240 && trimmed.contains("Clone + Send")
2241 && !trimmed.contains("Sync")
2242 {
2243 let is_channel_like = trimmed.contains("Sender")
2244 || trimmed.contains("Receiver")
2245 || trimmed.contains("Channel")
2246 || trimmed.contains("Broadcast");
2247
2248 if is_channel_like {
2249 let location = format!("{}:{}", rel_path, idx + 1);
2250
2251 findings.push(Finding::new(
2252 self.metadata.id.clone(),
2253 self.metadata.name.clone(),
2254 self.metadata.default_severity,
2255 "Channel implementation with Clone + Send but missing Sync \
2256 bound. Concurrent cloning may cause data races. \
2257 Consider `T: Clone + Send + Sync`."
2258 .to_string(),
2259 location,
2260 String::new(),
2261 vec![trimmed.to_string()],
2262 None,
2263 ));
2264 }
2265 }
2266 }
2267 }
2268
2269 findings
2270 }
2271}
2272
2273pub struct PinContractViolationRule {
2283 metadata: RuleMetadata,
2284}
2285
2286impl PinContractViolationRule {
2287 pub fn new() -> Self {
2288 Self {
2289 metadata: RuleMetadata {
2290 id: "RUSTCOLA112".to_string(),
2291 name: "pin-contract-violation".to_string(),
2292 short_description: "Potential Pin contract violation through unsplit".to_string(),
2293 full_description: "Detects potential Pin contract violations through unsplit or \
2294 reconstruction patterns. When split IO types are recombined using unsplit(), \
2295 pinned data for !Unpin types may be moved incorrectly. Also detects unsafe \
2296 Pin::new_unchecked followed by potential moves. Based on RUSTSEC-2023-0005."
2297 .to_string(),
2298 help_uri: Some("https://rustsec.org/advisories/RUSTSEC-2023-0005.html".to_string()),
2299 default_severity: Severity::High,
2300 origin: RuleOrigin::BuiltIn,
2301 cwe_ids: Vec::new(),
2302 fix_suggestion: None,
2303 exploitability: Exploitability::default(),
2304 },
2305 }
2306 }
2307}
2308
2309impl Rule for PinContractViolationRule {
2310 fn metadata(&self) -> &RuleMetadata {
2311 &self.metadata
2312 }
2313
2314 fn evaluate(
2315 &self,
2316 package: &MirPackage,
2317 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
2318 ) -> Vec<Finding> {
2319 if package.crate_name == "mir-extractor" {
2320 return Vec::new();
2321 }
2322
2323 let mut findings = Vec::new();
2324 let crate_root = Path::new(&package.crate_root);
2325
2326 if !crate_root.exists() {
2327 return findings;
2328 }
2329
2330 for entry in WalkDir::new(crate_root)
2331 .into_iter()
2332 .filter_entry(|e| filter_entry(e))
2333 {
2334 let entry = match entry {
2335 Ok(e) => e,
2336 Err(_) => continue,
2337 };
2338
2339 if !entry.file_type().is_file() {
2340 continue;
2341 }
2342
2343 let path = entry.path();
2344 if path.extension() != Some(OsStr::new("rs")) {
2345 continue;
2346 }
2347
2348 let rel_path = path
2349 .strip_prefix(crate_root)
2350 .unwrap_or(path)
2351 .to_string_lossy()
2352 .replace('\\', "/");
2353
2354 let content = match fs::read_to_string(path) {
2355 Ok(c) => c,
2356 Err(_) => continue,
2357 };
2358
2359 let lines: Vec<&str> = content.lines().collect();
2360
2361 for (idx, line) in lines.iter().enumerate() {
2362 let trimmed = line.trim();
2363
2364 if trimmed.starts_with("//") {
2366 continue;
2367 }
2368
2369 if trimmed.contains(".unsplit(") {
2371 let location = format!("{}:{}", rel_path, idx + 1);
2372
2373 findings.push(Finding {
2374 rule_id: self.metadata.id.clone(),
2375 rule_name: self.metadata.name.clone(),
2376 severity: self.metadata.default_severity,
2377 message: "Use of unsplit() may violate Pin contract for !Unpin types. \
2378 When recombining split IO halves, pinned data may be moved incorrectly. \
2379 Ensure the underlying type is Unpin or use appropriate synchronization.".to_string(),
2380 function: location,
2381 function_signature: String::new(),
2382 evidence: vec![trimmed.to_string()],
2383 span: None,
2384 ..Default::default()
2385 });
2386 }
2387
2388 if trimmed.contains("Pin::new_unchecked") {
2390 let location = format!("{}:{}", rel_path, idx + 1);
2391
2392 findings.push(Finding::new(
2393 self.metadata.id.clone(),
2394 self.metadata.name.clone(),
2395 Severity::Medium,
2396 "Use of Pin::new_unchecked requires ensuring the pinned value \
2397 is never moved. Verify the value is not moved after pinning."
2398 .to_string(),
2399 location,
2400 String::new(),
2401 vec![trimmed.to_string()],
2402 None,
2403 ));
2404 }
2405 }
2406 }
2407
2408 findings
2409 }
2410}
2411
2412pub struct OneshotRaceAfterCloseRule {
2421 metadata: RuleMetadata,
2422}
2423
2424impl OneshotRaceAfterCloseRule {
2425 pub fn new() -> Self {
2426 Self {
2427 metadata: RuleMetadata {
2428 id: "RUSTCOLA113".to_string(),
2429 name: "oneshot-race-after-close".to_string(),
2430 short_description: "Potential race condition with oneshot channel close()"
2431 .to_string(),
2432 full_description:
2433 "Detects patterns where oneshot::Receiver::close() may be called \
2434 concurrently with send() or recv()/await operations. This pattern can cause \
2435 data races as the sender and receiver may concurrently access shared memory. \
2436 Based on RUSTSEC-2021-0124."
2437 .to_string(),
2438 help_uri: Some("https://rustsec.org/advisories/RUSTSEC-2021-0124.html".to_string()),
2439 default_severity: Severity::High,
2440 origin: RuleOrigin::BuiltIn,
2441 cwe_ids: Vec::new(),
2442 fix_suggestion: None,
2443 exploitability: Exploitability::default(),
2444 },
2445 }
2446 }
2447}
2448
2449impl Rule for OneshotRaceAfterCloseRule {
2450 fn metadata(&self) -> &RuleMetadata {
2451 &self.metadata
2452 }
2453
2454 fn evaluate(
2455 &self,
2456 package: &MirPackage,
2457 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
2458 ) -> Vec<Finding> {
2459 if package.crate_name == "mir-extractor" {
2460 return Vec::new();
2461 }
2462
2463 let mut findings = Vec::new();
2464 let crate_root = Path::new(&package.crate_root);
2465
2466 if !crate_root.exists() {
2467 return findings;
2468 }
2469
2470 for entry in WalkDir::new(crate_root)
2471 .into_iter()
2472 .filter_entry(|e| filter_entry(e))
2473 {
2474 let entry = match entry {
2475 Ok(e) => e,
2476 Err(_) => continue,
2477 };
2478
2479 if !entry.file_type().is_file() {
2480 continue;
2481 }
2482
2483 let path = entry.path();
2484 if path.extension() != Some(OsStr::new("rs")) {
2485 continue;
2486 }
2487
2488 let rel_path = path
2489 .strip_prefix(crate_root)
2490 .unwrap_or(path)
2491 .to_string_lossy()
2492 .replace('\\', "/");
2493
2494 let content = match fs::read_to_string(path) {
2495 Ok(c) => c,
2496 Err(_) => continue,
2497 };
2498
2499 if !content.contains("oneshot::") && !content.contains("oneshot::{") {
2501 continue;
2502 }
2503
2504 let lines: Vec<&str> = content.lines().collect();
2505 let mut has_close_call = false;
2506 let mut close_line = 0;
2507
2508 for (idx, line) in lines.iter().enumerate() {
2509 let trimmed = line.trim();
2510
2511 if trimmed.starts_with("//") {
2513 continue;
2514 }
2515
2516 if trimmed.contains(".close()") {
2518 has_close_call = true;
2519 close_line = idx + 1;
2520 }
2521
2522 if has_close_call {
2524 let in_spawn_context = lines[..idx]
2525 .iter()
2526 .rev()
2527 .take(10)
2528 .any(|l| l.contains("spawn") || l.contains("thread::"));
2529
2530 if in_spawn_context {
2531 if trimmed.contains(".send(") || trimmed.contains(".try_recv(") {
2532 let location = format!("{}:{}", rel_path, idx + 1);
2533
2534 findings.push(Finding {
2535 rule_id: self.metadata.id.clone(),
2536 rule_name: self.metadata.name.clone(),
2537 severity: self.metadata.default_severity,
2538 message: format!(
2539 "Oneshot channel operation after close() at line {}. \
2540 Concurrent close(), send(), and recv() operations can cause data races. \
2541 Ensure proper synchronization between channel halves.",
2542 close_line
2543 ),
2544 function: location,
2545 function_signature: String::new(),
2546 evidence: vec![trimmed.to_string()],
2547 span: None,
2548 ..Default::default()
2549 });
2550 }
2551 }
2552 }
2553 }
2554 }
2555
2556 findings
2557 }
2558}
2559
2560pub struct AsyncSignalUnsafeInHandlerRule {
2570 metadata: RuleMetadata,
2571}
2572
2573impl AsyncSignalUnsafeInHandlerRule {
2574 pub fn new() -> Self {
2575 Self {
2576 metadata: RuleMetadata {
2577 id: "RUSTCOLA109".to_string(),
2578 name: "async-signal-unsafe-in-handler".to_string(),
2579 short_description: "Async-signal-unsafe operation in signal handler".to_string(),
2580 full_description: "Detects async-signal-unsafe operations inside signal handlers. \
2581 Signal handlers run asynchronously and interrupt normal execution, so only \
2582 'async-signal-safe' functions may be called. Unsafe operations include: \
2583 heap allocation (Box, Vec, String, format!), I/O (println!, eprintln!), \
2584 locking (Mutex, RwLock), and most standard library functions."
2585 .to_string(),
2586 help_uri: Some(
2587 "https://man7.org/linux/man-pages/man7/signal-safety.7.html".to_string(),
2588 ),
2589 default_severity: Severity::High,
2590 origin: RuleOrigin::BuiltIn,
2591 cwe_ids: Vec::new(),
2592 fix_suggestion: None,
2593 exploitability: Exploitability::default(),
2594 },
2595 }
2596 }
2597
2598 fn unsafe_operations() -> &'static [(&'static str, &'static str)] {
2600 &[
2601 ("println!", "I/O operations are not async-signal-safe"),
2602 ("eprintln!", "I/O operations are not async-signal-safe"),
2603 ("print!", "I/O operations are not async-signal-safe"),
2604 ("eprint!", "I/O operations are not async-signal-safe"),
2605 ("format!", "format! allocates memory, not async-signal-safe"),
2606 ("vec!", "vec! allocates memory, not async-signal-safe"),
2607 ("Box::new", "Box allocates memory, not async-signal-safe"),
2608 ("Vec::new", "Vec may allocate, not async-signal-safe"),
2609 ("String::new", "String may allocate, not async-signal-safe"),
2610 (".to_string()", "to_string allocates, not async-signal-safe"),
2611 (".to_owned()", "to_owned allocates, not async-signal-safe"),
2612 ("Mutex::new", "Creating locks in handlers is dangerous"),
2613 (".lock()", "Locking is not async-signal-safe"),
2614 (".read()", "RwLock read is not async-signal-safe"),
2615 (".write()", "RwLock write is not async-signal-safe"),
2616 ("std::io::", "Most std::io is not async-signal-safe"),
2617 ("std::fs::", "File operations are not async-signal-safe"),
2618 ]
2619 }
2620}
2621
2622impl Rule for AsyncSignalUnsafeInHandlerRule {
2623 fn metadata(&self) -> &RuleMetadata {
2624 &self.metadata
2625 }
2626
2627 fn evaluate(
2628 &self,
2629 package: &MirPackage,
2630 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
2631 ) -> Vec<Finding> {
2632 if package.crate_name == "mir-extractor" {
2633 return Vec::new();
2634 }
2635
2636 let mut findings = Vec::new();
2637 let crate_root = Path::new(&package.crate_root);
2638
2639 if !crate_root.exists() {
2640 return findings;
2641 }
2642
2643 for entry in WalkDir::new(crate_root)
2644 .into_iter()
2645 .filter_entry(|e| filter_entry(e))
2646 {
2647 let entry = match entry {
2648 Ok(e) => e,
2649 Err(_) => continue,
2650 };
2651
2652 if !entry.file_type().is_file() {
2653 continue;
2654 }
2655
2656 let path = entry.path();
2657 if path.extension() != Some(OsStr::new("rs")) {
2658 continue;
2659 }
2660
2661 let rel_path = path
2662 .strip_prefix(crate_root)
2663 .unwrap_or(path)
2664 .to_string_lossy()
2665 .replace('\\', "/");
2666
2667 let content = match fs::read_to_string(path) {
2668 Ok(c) => c,
2669 Err(_) => continue,
2670 };
2671
2672 if !content.contains("signal")
2674 && !content.contains("Signal")
2675 && !content.contains("ctrlc")
2676 && !content.contains("SIGINT")
2677 && !content.contains("SIGTERM")
2678 && !content.contains("SIGHUP")
2679 {
2680 continue;
2681 }
2682
2683 let lines: Vec<&str> = content.lines().collect();
2684 let mut in_signal_handler = false;
2685 let mut handler_start_line = 0;
2686 let mut brace_depth = 0;
2687
2688 for (idx, line) in lines.iter().enumerate() {
2689 let trimmed = line.trim();
2690
2691 if trimmed.starts_with("//") {
2693 continue;
2694 }
2695
2696 let is_handler_start = trimmed.contains("signal::")
2698 || trimmed.contains("ctrlc::set_handler")
2699 || trimmed.contains("set_handler")
2700 || (trimmed.contains("signal") && trimmed.contains("move ||"))
2701 || (trimmed.contains("signal") && trimmed.contains("move |"))
2702 || trimmed.contains("SigAction::new");
2703
2704 if is_handler_start && !in_signal_handler {
2705 in_signal_handler = true;
2706 handler_start_line = idx;
2707 brace_depth = 0;
2708 }
2709
2710 if in_signal_handler {
2711 brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
2712 brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
2713
2714 for (pattern, reason) in Self::unsafe_operations() {
2716 if trimmed.contains(pattern) {
2717 let location = format!("{}:{}", rel_path, idx + 1);
2718
2719 findings.push(Finding {
2720 rule_id: self.metadata.id.clone(),
2721 rule_name: self.metadata.name.clone(),
2722 severity: self.metadata.default_severity,
2723 message: format!(
2724 "Async-signal-unsafe operation `{}` in signal handler (started at line {}). {}",
2725 pattern, handler_start_line + 1, reason
2726 ),
2727 function: location,
2728 function_signature: String::new(),
2729 evidence: vec![trimmed.to_string()],
2730 span: None,
2731 ..Default::default()
2732 });
2733 }
2734 }
2735
2736 if brace_depth <= 0 && idx > handler_start_line {
2738 in_signal_handler = false;
2739 }
2740 }
2741 }
2742 }
2743
2744 findings
2745 }
2746}
2747
2748pub struct OnceCellTocTouRule {
2759 metadata: RuleMetadata,
2760}
2761
2762impl OnceCellTocTouRule {
2763 pub fn new() -> Self {
2764 Self {
2765 metadata: RuleMetadata {
2766 id: "RUSTCOLA100".to_string(),
2767 name: "oncecell-toctou-race".to_string(),
2768 short_description: "Potential TOCTOU race with OnceCell".to_string(),
2769 full_description: "Detects patterns where OnceCell::get() is checked before \
2770 calling get_or_init(). This creates a TOCTOU race: another thread may \
2771 initialize the cell between the check and the use. Use get_or_init() \
2772 directly without pre-checking, or use get_or_try_init() for fallible init."
2773 .to_string(),
2774 help_uri: Some(
2775 "https://doc.rust-lang.org/std/cell/struct.OnceCell.html".to_string(),
2776 ),
2777 default_severity: Severity::Medium,
2778 origin: RuleOrigin::BuiltIn,
2779 cwe_ids: Vec::new(),
2780 fix_suggestion: None,
2781 exploitability: Exploitability::default(),
2782 },
2783 }
2784 }
2785
2786 fn toctou_patterns() -> &'static [(&'static str, &'static str)] {
2788 &[
2789 (
2790 ".get().is_none()",
2791 "checking get().is_none() then initializing",
2792 ),
2793 (".get().is_some()", "checking get().is_some() before using"),
2794 ("if let None = ", "pattern matching None before get_or_init"),
2795 ("if cell.get() == None", "comparing get() to None"),
2796 ("match .get() {", "matching on get() result"),
2797 ]
2798 }
2799}
2800
2801impl Rule for OnceCellTocTouRule {
2802 fn metadata(&self) -> &RuleMetadata {
2803 &self.metadata
2804 }
2805
2806 fn evaluate(
2807 &self,
2808 package: &MirPackage,
2809 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
2810 ) -> Vec<Finding> {
2811 if package.crate_name == "mir-extractor" {
2812 return Vec::new();
2813 }
2814
2815 let mut findings = Vec::new();
2816 let crate_root = Path::new(&package.crate_root);
2817
2818 if !crate_root.exists() {
2819 return findings;
2820 }
2821
2822 for entry in WalkDir::new(crate_root)
2823 .into_iter()
2824 .filter_entry(|e| filter_entry(e))
2825 {
2826 let entry = match entry {
2827 Ok(e) => e,
2828 Err(_) => continue,
2829 };
2830
2831 if !entry.file_type().is_file() {
2832 continue;
2833 }
2834
2835 let path = entry.path();
2836 if path.extension() != Some(OsStr::new("rs")) {
2837 continue;
2838 }
2839
2840 let rel_path = path
2841 .strip_prefix(crate_root)
2842 .unwrap_or(path)
2843 .to_string_lossy()
2844 .replace('\\', "/");
2845
2846 let content = match fs::read_to_string(path) {
2847 Ok(c) => c,
2848 Err(_) => continue,
2849 };
2850
2851 if !content.contains("OnceCell")
2853 && !content.contains("OnceLock")
2854 && !content.contains("once_cell")
2855 {
2856 continue;
2857 }
2858
2859 let lines: Vec<&str> = content.lines().collect();
2860
2861 for (idx, line) in lines.iter().enumerate() {
2862 let trimmed = line.trim();
2863
2864 if trimmed.starts_with("//") {
2866 continue;
2867 }
2868
2869 for (pattern, description) in Self::toctou_patterns() {
2871 if trimmed.contains(pattern) {
2872 let has_init_nearby = lines[idx..]
2874 .iter()
2875 .take(5)
2876 .any(|l| l.contains("get_or_init") || l.contains("set("));
2877
2878 if has_init_nearby {
2879 let location = format!("{}:{}", rel_path, idx + 1);
2880
2881 findings.push(Finding {
2882 rule_id: self.metadata.id.clone(),
2883 rule_name: self.metadata.name.clone(),
2884 severity: self.metadata.default_severity,
2885 message: format!(
2886 "Potential TOCTOU race: {} followed by initialization. \
2887 Another thread may initialize between check and use. \
2888 Use get_or_init() directly without pre-checking.",
2889 description
2890 ),
2891 function: location,
2892 function_signature: String::new(),
2893 evidence: vec![trimmed.to_string()],
2894 span: None,
2895 ..Default::default()
2896 });
2897 }
2898 }
2899 }
2900 }
2901 }
2902
2903 findings
2904 }
2905}
2906
2907pub struct PanicWhileHoldingLockRule {
2916 metadata: RuleMetadata,
2917}
2918
2919impl PanicWhileHoldingLockRule {
2920 pub fn new() -> Self {
2921 Self {
2922 metadata: RuleMetadata {
2923 id: "RUSTCOLA117".to_string(),
2924 name: "panic-while-holding-lock".to_string(),
2925 short_description: "Potential panic while holding lock".to_string(),
2926 full_description: "Detects panic-prone operations (unwrap, expect, assert, \
2927 indexing) while a MutexGuard or RwLockGuard is held. Panicking while \
2928 holding a lock poisons the mutex, making it unusable for other threads \
2929 and causing cascading failures."
2930 .to_string(),
2931 help_uri: Some(
2932 "https://doc.rust-lang.org/std/sync/struct.Mutex.html#poisoning".to_string(),
2933 ),
2934 default_severity: Severity::Medium,
2935 origin: RuleOrigin::BuiltIn,
2936 cwe_ids: Vec::new(),
2937 fix_suggestion: None,
2938 exploitability: Exploitability::default(),
2939 },
2940 }
2941 }
2942
2943 fn panic_patterns() -> &'static [&'static str] {
2945 &[
2946 ".unwrap()",
2947 ".expect(",
2948 "panic!",
2949 "assert!",
2950 "assert_eq!",
2951 "assert_ne!",
2952 "unreachable!",
2953 "unimplemented!",
2954 "todo!",
2955 ]
2956 }
2957
2958 fn lock_patterns() -> &'static [&'static str] {
2960 &[
2961 ".lock()",
2962 ".read()",
2963 ".write()",
2964 ".try_lock()",
2965 ".try_read()",
2966 ".try_write()",
2967 ]
2968 }
2969}
2970
2971impl Rule for PanicWhileHoldingLockRule {
2972 fn metadata(&self) -> &RuleMetadata {
2973 &self.metadata
2974 }
2975
2976 fn evaluate(
2977 &self,
2978 package: &MirPackage,
2979 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
2980 ) -> Vec<Finding> {
2981 if package.crate_name == "mir-extractor" {
2982 return Vec::new();
2983 }
2984
2985 let mut findings = Vec::new();
2986 let crate_root = Path::new(&package.crate_root);
2987
2988 if !crate_root.exists() {
2989 return findings;
2990 }
2991
2992 for entry in WalkDir::new(crate_root)
2993 .into_iter()
2994 .filter_entry(|e| filter_entry(e))
2995 {
2996 let entry = match entry {
2997 Ok(e) => e,
2998 Err(_) => continue,
2999 };
3000
3001 if !entry.file_type().is_file() {
3002 continue;
3003 }
3004
3005 let path = entry.path();
3006 if path.extension() != Some(OsStr::new("rs")) {
3007 continue;
3008 }
3009
3010 let rel_path = path
3011 .strip_prefix(crate_root)
3012 .unwrap_or(path)
3013 .to_string_lossy()
3014 .replace('\\', "/");
3015
3016 let content = match fs::read_to_string(path) {
3017 Ok(c) => c,
3018 Err(_) => continue,
3019 };
3020
3021 if !content.contains("Mutex") && !content.contains("RwLock") {
3023 continue;
3024 }
3025
3026 let lines: Vec<&str> = content.lines().collect();
3027 let mut in_lock_scope = false;
3028 let mut lock_start_line = 0;
3029 let mut brace_depth_at_lock = 0;
3030 let mut current_brace_depth = 0;
3031
3032 for (idx, line) in lines.iter().enumerate() {
3033 let trimmed = line.trim();
3034
3035 if trimmed.starts_with("//") {
3037 continue;
3038 }
3039
3040 current_brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
3042 current_brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
3043
3044 for pattern in Self::lock_patterns() {
3046 if trimmed.contains(pattern) && trimmed.contains("let ") {
3047 in_lock_scope = true;
3048 lock_start_line = idx;
3049 brace_depth_at_lock = current_brace_depth;
3050 }
3051 }
3052
3053 if in_lock_scope {
3055 for pattern in Self::panic_patterns() {
3056 if trimmed.contains(pattern) {
3057 let location = format!("{}:{}", rel_path, idx + 1);
3058
3059 findings.push(Finding {
3060 rule_id: self.metadata.id.clone(),
3061 rule_name: self.metadata.name.clone(),
3062 severity: self.metadata.default_severity,
3063 message: format!(
3064 "Panic-prone operation `{}` while holding lock (acquired at line {}). \
3065 Panicking will poison the mutex, making it unusable. \
3066 Consider using fallible alternatives or dropping the guard first.",
3067 pattern.trim_end_matches('('), lock_start_line + 1
3068 ),
3069 function: location,
3070 function_signature: String::new(),
3071 evidence: vec![trimmed.to_string()],
3072 span: None,
3073 ..Default::default()
3074 });
3075 }
3076 }
3077
3078 if current_brace_depth < brace_depth_at_lock {
3080 in_lock_scope = false;
3081 }
3082 }
3083 }
3084 }
3085
3086 findings
3087 }
3088}
3089
3090pub struct ClosureEscapingRefsRule {
3098 metadata: RuleMetadata,
3099}
3100
3101impl ClosureEscapingRefsRule {
3102 pub fn new() -> Self {
3103 Self {
3104 metadata: RuleMetadata {
3105 id: "RUSTCOLA119".to_string(),
3106 name: "closure-escaping-refs".to_string(),
3107 short_description: "Closure may capture escaping references".to_string(),
3108 full_description: "Detects closures passed to spawn/thread functions that \
3109 capture local references. These closures outlive the captured references, \
3110 leading to use-after-free. Use move closures or Arc for shared ownership."
3111 .to_string(),
3112 help_uri: None,
3113 default_severity: Severity::High,
3114 origin: RuleOrigin::BuiltIn,
3115 cwe_ids: Vec::new(),
3116 fix_suggestion: None,
3117 exploitability: Exploitability::default(),
3118 },
3119 }
3120 }
3121
3122 fn escaping_closure_receivers() -> &'static [&'static str] {
3124 &[
3125 "thread::spawn",
3126 "spawn(",
3127 "spawn_blocking(",
3128 "spawn_local(",
3129 "task::spawn",
3130 "tokio::spawn",
3131 "async_std::spawn",
3132 "rayon::spawn",
3133 "thread::Builder",
3134 ".spawn(",
3135 "std::thread::spawn",
3136 ]
3137 }
3138
3139 fn ref_capture_patterns() -> &'static [&'static str] {
3141 &[
3142 "|&",
3144 "| &",
3145 ".as_ref()",
3147 ".borrow()",
3148 ]
3149 }
3150}
3151
3152impl Rule for ClosureEscapingRefsRule {
3153 fn metadata(&self) -> &RuleMetadata {
3154 &self.metadata
3155 }
3156
3157 fn evaluate(
3158 &self,
3159 package: &MirPackage,
3160 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
3161 ) -> Vec<Finding> {
3162 let mut findings = Vec::new();
3163 let crate_root = Path::new(&package.crate_root);
3164
3165 if !crate_root.exists() {
3166 return findings;
3167 }
3168
3169 for entry in WalkDir::new(crate_root)
3170 .into_iter()
3171 .filter_entry(|e| filter_entry(e))
3172 {
3173 let entry = match entry {
3174 Ok(e) => e,
3175 Err(_) => continue,
3176 };
3177
3178 if !entry.file_type().is_file() {
3179 continue;
3180 }
3181
3182 let path = entry.path();
3183 if path.extension() != Some(OsStr::new("rs")) {
3184 continue;
3185 }
3186
3187 let rel_path = path
3188 .strip_prefix(crate_root)
3189 .unwrap_or(path)
3190 .to_string_lossy()
3191 .replace('\\', "/");
3192
3193 let content = match fs::read_to_string(path) {
3194 Ok(c) => c,
3195 Err(_) => continue,
3196 };
3197
3198 let has_spawn = Self::escaping_closure_receivers()
3200 .iter()
3201 .any(|p| content.contains(p));
3202 if !has_spawn {
3203 continue;
3204 }
3205
3206 let lines: Vec<&str> = content.lines().collect();
3207
3208 for (idx, line) in lines.iter().enumerate() {
3209 let trimmed = line.trim();
3210
3211 if trimmed.starts_with("//") {
3213 continue;
3214 }
3215
3216 for spawn_fn in Self::escaping_closure_receivers() {
3218 if trimmed.contains(spawn_fn) {
3219 let has_closure = trimmed.contains('|');
3221 let is_move_closure =
3222 trimmed.contains("move |") || trimmed.contains("move|");
3223
3224 if has_closure && !is_move_closure {
3225 let location = format!("{}:{}", rel_path, idx + 1);
3227
3228 findings.push(Finding {
3229 rule_id: self.metadata.id.clone(),
3230 rule_name: self.metadata.name.clone(),
3231 severity: Severity::Medium,
3232 message: format!(
3233 "Non-move closure passed to '{}'. Closures passed to spawn \
3234 functions must use 'move' to take ownership of captured \
3235 variables, otherwise captured references may dangle. \
3236 Add 'move' keyword: `move |...| {{ ... }}`",
3237 spawn_fn.trim_end_matches('(')
3238 ),
3239 function: location,
3240 function_signature: String::new(),
3241 evidence: vec![trimmed.to_string()],
3242 span: None,
3243 ..Default::default()
3244 });
3245 }
3246
3247 if has_closure {
3250 for ref_pattern in Self::ref_capture_patterns() {
3251 if trimmed.contains(ref_pattern) {
3252 let location = format!("{}:{}", rel_path, idx + 1);
3253
3254 findings.push(Finding {
3255 rule_id: self.metadata.id.clone(),
3256 rule_name: self.metadata.name.clone(),
3257 severity: Severity::High,
3258 message: format!(
3259 "Closure passed to spawn may capture reference (pattern: '{}'). \
3260 References captured in spawn closures will dangle when the \
3261 spawning function returns. Use Arc or clone the data instead.",
3262 ref_pattern
3263 ),
3264 function: location,
3265 function_signature: String::new(),
3266 evidence: vec![trimmed.to_string()],
3267 span: None,
3268 ..Default::default()
3269 });
3270 break;
3271 }
3272 }
3273 }
3274 }
3275 }
3276 }
3277 }
3278
3279 findings
3280 }
3281}
3282
3283pub struct ExecutorStarvationRule {
3292 metadata: RuleMetadata,
3293}
3294
3295impl ExecutorStarvationRule {
3296 pub fn new() -> Self {
3297 Self {
3298 metadata: RuleMetadata {
3299 id: "RUSTCOLA121".to_string(),
3300 name: "executor-starvation".to_string(),
3301 short_description: "CPU-bound work in async context".to_string(),
3302 full_description: "Detects CPU-bound operations (tight loops, heavy computation) \
3303 in async functions that may starve the executor. Long-running synchronous \
3304 work blocks the executor thread. Use tokio::task::spawn_blocking or \
3305 add yield points with tokio::task::yield_now()."
3306 .to_string(),
3307 help_uri: Some("https://tokio.rs/tokio/topics/bridging".to_string()),
3308 default_severity: Severity::Medium,
3309 origin: RuleOrigin::BuiltIn,
3310 cwe_ids: Vec::new(),
3311 fix_suggestion: None,
3312 exploitability: Exploitability::default(),
3313 },
3314 }
3315 }
3316
3317 fn cpu_bound_patterns() -> &'static [(&'static str, &'static str)] {
3319 &[
3320 ("loop {", "CPU-bound loop without yield_now() or await"),
3322 ("while true", "Infinite loop without yield"),
3323 ("for _ in 0..", "Large iteration without yield"),
3324 (".par_iter()", "Rayon parallel iteration blocks executor"),
3326 ("hash(", "Cryptographic hashing is CPU-bound"),
3328 ("encrypt(", "Encryption is CPU-bound"),
3329 ("decrypt(", "Decryption is CPU-bound"),
3330 ("compress(", "Compression is CPU-bound"),
3332 ("decompress(", "Decompression is CPU-bound"),
3333 ("GzEncoder", "Gzip encoding is CPU-bound"),
3334 ("GzDecoder", "Gzip decoding is CPU-bound"),
3335 ]
3336 }
3337}
3338
3339impl Rule for ExecutorStarvationRule {
3340 fn metadata(&self) -> &RuleMetadata {
3341 &self.metadata
3342 }
3343
3344 fn evaluate(
3345 &self,
3346 package: &MirPackage,
3347 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
3348 ) -> Vec<Finding> {
3349 let mut findings = Vec::new();
3350 let crate_root = Path::new(&package.crate_root);
3351
3352 if !crate_root.exists() {
3353 return findings;
3354 }
3355
3356 for entry in WalkDir::new(crate_root)
3357 .into_iter()
3358 .filter_entry(|e| filter_entry(e))
3359 {
3360 let entry = match entry {
3361 Ok(e) => e,
3362 Err(_) => continue,
3363 };
3364
3365 if !entry.file_type().is_file() {
3366 continue;
3367 }
3368
3369 let path = entry.path();
3370 if path.extension() != Some(OsStr::new("rs")) {
3371 continue;
3372 }
3373
3374 let rel_path = path
3375 .strip_prefix(crate_root)
3376 .unwrap_or(path)
3377 .to_string_lossy()
3378 .replace('\\', "/");
3379
3380 let content = match fs::read_to_string(path) {
3381 Ok(c) => c,
3382 Err(_) => continue,
3383 };
3384
3385 if !content.contains("async fn") && !content.contains("async move") {
3387 continue;
3388 }
3389
3390 let lines: Vec<&str> = content.lines().collect();
3391 let mut in_async_fn = false;
3392 let mut async_fn_name = String::new();
3393 let mut brace_depth = 0;
3394 let mut async_start_depth = 0;
3395
3396 for (idx, line) in lines.iter().enumerate() {
3397 let trimmed = line.trim();
3398
3399 if trimmed.starts_with("//") {
3401 continue;
3402 }
3403
3404 if (trimmed.contains("async fn ") || trimmed.contains("async move")) && !in_async_fn
3406 {
3407 in_async_fn = true;
3408 async_start_depth = brace_depth;
3409 if let Some(fn_pos) = trimmed.find("fn ") {
3411 let after_fn = &trimmed[fn_pos + 3..];
3412 async_fn_name = after_fn
3413 .split(|c| c == '(' || c == '<')
3414 .next()
3415 .unwrap_or("")
3416 .trim()
3417 .to_string();
3418 }
3419 }
3420
3421 brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
3423 brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
3424
3425 if in_async_fn && brace_depth <= async_start_depth && trimmed.contains('}') {
3427 in_async_fn = false;
3428 async_fn_name.clear();
3429 }
3430
3431 if in_async_fn {
3433 if trimmed.contains("spawn_blocking") || trimmed.contains("block_in_place") {
3435 continue;
3436 }
3437
3438 for (pattern, description) in Self::cpu_bound_patterns() {
3439 if trimmed.contains(pattern) {
3440 let start_idx = idx.saturating_sub(5);
3442 let end_idx = (idx + 5).min(lines.len() - 1);
3443 let has_nearby_yield = lines[start_idx..=end_idx]
3444 .iter()
3445 .any(|l| l.contains(".await") || l.contains("yield_now"));
3446
3447 if !has_nearby_yield {
3448 let location = format!("{}:{}", rel_path, idx + 1);
3449
3450 findings.push(Finding {
3451 rule_id: self.metadata.id.clone(),
3452 rule_name: self.metadata.name.clone(),
3453 severity: self.metadata.default_severity,
3454 message: format!(
3455 "Potential executor starvation in async function '{}': {}. \
3456 Consider using tokio::task::spawn_blocking() for CPU-bound work \
3457 or adding yield points with tokio::task::yield_now().await",
3458 async_fn_name, description
3459 ),
3460 function: location,
3461 function_signature: String::new(),
3462 evidence: vec![trimmed.to_string()],
3463 span: None,
3464 ..Default::default()
3465 });
3466 }
3467 }
3468 }
3469 }
3470 }
3471 }
3472
3473 findings
3474 }
3475}
3476
3477pub struct AsyncDropCorrectnessRule {
3485 metadata: RuleMetadata,
3486}
3487
3488impl AsyncDropCorrectnessRule {
3489 pub fn new() -> Self {
3490 Self {
3491 metadata: RuleMetadata {
3492 id: "RUSTCOLA122".to_string(),
3493 name: "async-drop-correctness".to_string(),
3494 short_description: "Async resource may be dropped without cleanup".to_string(),
3495 full_description: "Detects async resources (network connections, file handles, \
3496 database connections) that may be dropped without explicit cleanup. In async \
3497 code, Drop::drop() is synchronous and cannot await cleanup operations. Use \
3498 explicit .shutdown(), .close(), or .flush() before dropping async resources."
3499 .to_string(),
3500 help_uri: None,
3501 default_severity: Severity::Medium,
3502 origin: RuleOrigin::BuiltIn,
3503 cwe_ids: Vec::new(),
3504 fix_suggestion: None,
3505 exploitability: Exploitability::default(),
3506 },
3507 }
3508 }
3509
3510 fn async_resource_types() -> &'static [(&'static str, &'static str)] {
3512 &[
3513 ("TcpStream", "Use .shutdown() before dropping"),
3514 ("TcpListener", "Use .shutdown() or explicit close"),
3515 ("UnixStream", "Use .shutdown() before dropping"),
3516 (
3517 "File",
3518 "Use .flush().await and .sync_all().await before dropping",
3519 ),
3520 ("BufWriter", "Use .flush().await before dropping"),
3521 ("BufReader", "Ensure underlying reader is properly closed"),
3522 ("Pool", "Use .close().await for connection pools"),
3523 (
3524 "Client",
3525 "Use .close() or explicit shutdown for HTTP clients",
3526 ),
3527 ("Connection", "Close database connections explicitly"),
3528 ("Sender", "Drop sender explicitly or use .closed().await"),
3529 ("WebSocket", "Use .close().await before dropping"),
3530 ]
3531 }
3532
3533 fn cleanup_methods() -> &'static [&'static str] {
3535 &[
3536 ".shutdown()",
3537 ".close()",
3538 ".flush()",
3539 ".sync_all()",
3540 ".sync_data()",
3541 "drop(",
3542 "std::mem::drop(",
3543 ".abort()",
3544 ".cancel()",
3545 ]
3546 }
3547}
3548
3549impl Rule for AsyncDropCorrectnessRule {
3550 fn metadata(&self) -> &RuleMetadata {
3551 &self.metadata
3552 }
3553
3554 fn evaluate(
3555 &self,
3556 package: &MirPackage,
3557 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
3558 ) -> Vec<Finding> {
3559 if package.crate_name == "mir-extractor" {
3560 return Vec::new();
3561 }
3562
3563 let mut findings = Vec::new();
3564 let crate_root = Path::new(&package.crate_root);
3565
3566 if !crate_root.exists() {
3567 return findings;
3568 }
3569
3570 for entry in WalkDir::new(crate_root)
3571 .into_iter()
3572 .filter_entry(|e| filter_entry(e))
3573 {
3574 let entry = match entry {
3575 Ok(e) => e,
3576 Err(_) => continue,
3577 };
3578
3579 if !entry.file_type().is_file() {
3580 continue;
3581 }
3582
3583 let path = entry.path();
3584 if path.extension() != Some(std::ffi::OsStr::new("rs")) {
3585 continue;
3586 }
3587
3588 let rel_path = path
3589 .strip_prefix(crate_root)
3590 .unwrap_or(path)
3591 .to_string_lossy()
3592 .replace('\\', "/");
3593
3594 let content = match std::fs::read_to_string(path) {
3595 Ok(c) => c,
3596 Err(_) => continue,
3597 };
3598
3599 let lines: Vec<&str> = content.lines().collect();
3600 let mut in_async_fn = false;
3601 let mut async_fn_name = String::new();
3602 let mut resource_declarations: Vec<(usize, String, String)> = Vec::new(); for (idx, line) in lines.iter().enumerate() {
3605 let trimmed = line.trim();
3606
3607 if trimmed.starts_with("//") {
3609 continue;
3610 }
3611
3612 if trimmed.contains("async fn ") {
3614 in_async_fn = true;
3615 if let Some(fn_pos) = trimmed.find("fn ") {
3616 let after_fn = &trimmed[fn_pos + 3..];
3617 async_fn_name = after_fn
3618 .split(|c| c == '(' || c == '<')
3619 .next()
3620 .unwrap_or("")
3621 .trim()
3622 .to_string();
3623 }
3624 resource_declarations.clear();
3625 }
3626
3627 if !in_async_fn {
3628 continue;
3629 }
3630
3631 for (resource_type, advice) in Self::async_resource_types() {
3633 if trimmed.contains(resource_type)
3634 && (trimmed.contains("let ") || trimmed.contains("mut "))
3635 && (trimmed.contains(".await")
3636 || trimmed.contains("::new")
3637 || trimmed.contains("::connect"))
3638 {
3639 if let Some(let_pos) = trimmed.find("let ") {
3641 let after_let = &trimmed[let_pos + 4..];
3642 let var_name: String = after_let
3643 .split(|c: char| c == ':' || c == '=' || c.is_whitespace())
3644 .next()
3645 .unwrap_or("")
3646 .replace("mut", "")
3647 .trim()
3648 .to_string();
3649 if !var_name.is_empty() {
3650 resource_declarations.push((idx, var_name, (*advice).to_string()));
3651 }
3652 }
3653 }
3654 }
3655
3656 for cleanup in Self::cleanup_methods() {
3658 if trimmed.contains(cleanup) {
3659 resource_declarations.retain(|(_, var, _)| !trimmed.contains(var.as_str()));
3661 }
3662 }
3663
3664 if trimmed == "}" && in_async_fn {
3666 for (decl_line, var_name, advice) in &resource_declarations {
3668 let location = format!("{}:{}", rel_path, decl_line + 1);
3669 findings.push(Finding {
3670 rule_id: self.metadata.id.clone(),
3671 rule_name: self.metadata.name.clone(),
3672 severity: self.metadata.default_severity,
3673 message: format!(
3674 "Async resource '{}' in function '{}' may be dropped without cleanup. {}",
3675 var_name, async_fn_name, advice
3676 ),
3677 function: location,
3678 function_signature: async_fn_name.clone(),
3679 evidence: vec![lines.get(*decl_line).unwrap_or(&"").to_string()],
3680 span: None,
3681 ..Default::default()
3682 });
3683 }
3684 in_async_fn = false;
3685 resource_declarations.clear();
3686 }
3687 }
3688 }
3689
3690 findings
3691 }
3692}
3693
3694pub struct PanicInDropImplRule {
3701 metadata: RuleMetadata,
3702}
3703
3704impl PanicInDropImplRule {
3705 pub fn new() -> Self {
3706 Self {
3707 metadata: RuleMetadata {
3708 id: "RUSTCOLA124".to_string(),
3709 name: "panic-in-drop-impl".to_string(),
3710 short_description: "Panic-prone code in Drop implementation".to_string(),
3711 full_description: "Detects panic-prone operations (unwrap, expect, panic!, \
3712 assert!, indexing) in Drop implementations. If Drop panics during stack \
3713 unwinding from another panic, the process will abort. Use Option::take(), \
3714 logging, or ignore errors in Drop."
3715 .to_string(),
3716 help_uri: Some("https://doc.rust-lang.org/std/ops/trait.Drop.html".to_string()),
3717 default_severity: Severity::High,
3718 origin: RuleOrigin::BuiltIn,
3719 cwe_ids: Vec::new(),
3720 fix_suggestion: None,
3721 exploitability: Exploitability::default(),
3722 },
3723 }
3724 }
3725
3726 fn panic_patterns() -> &'static [(&'static str, &'static str)] {
3728 &[
3729 (
3730 ".unwrap()",
3731 "Use .ok(), .unwrap_or_default(), or log errors instead",
3732 ),
3733 (
3734 ".expect(",
3735 "Use .ok(), .unwrap_or_default(), or log errors instead",
3736 ),
3737 (
3738 "panic!(",
3739 "Never panic in Drop - log error or silently ignore",
3740 ),
3741 ("unreachable!(", "Replace with logging or silent handling"),
3742 (
3743 "unimplemented!(",
3744 "Implement proper cleanup or silently skip",
3745 ),
3746 ("todo!(", "Complete implementation before production use"),
3747 ("assert!(", "Use debug_assert! or remove assertion"),
3748 ("assert_eq!(", "Use debug_assert_eq! or remove assertion"),
3749 ("assert_ne!(", "Use debug_assert_ne! or remove assertion"),
3750 ("[", "Use .get() instead of direct indexing to avoid panic"),
3751 ]
3752 }
3753}
3754
3755impl Rule for PanicInDropImplRule {
3756 fn metadata(&self) -> &RuleMetadata {
3757 &self.metadata
3758 }
3759
3760 fn evaluate(
3761 &self,
3762 package: &MirPackage,
3763 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
3764 ) -> Vec<Finding> {
3765 if package.crate_name == "mir-extractor" {
3766 return Vec::new();
3767 }
3768
3769 let mut findings = Vec::new();
3770 let crate_root = Path::new(&package.crate_root);
3771
3772 if !crate_root.exists() {
3773 return findings;
3774 }
3775
3776 for entry in WalkDir::new(crate_root)
3777 .into_iter()
3778 .filter_entry(|e| filter_entry(e))
3779 {
3780 let entry = match entry {
3781 Ok(e) => e,
3782 Err(_) => continue,
3783 };
3784
3785 if !entry.file_type().is_file() {
3786 continue;
3787 }
3788
3789 let path = entry.path();
3790 if path.extension() != Some(std::ffi::OsStr::new("rs")) {
3791 continue;
3792 }
3793
3794 let rel_path = path
3795 .strip_prefix(crate_root)
3796 .unwrap_or(path)
3797 .to_string_lossy()
3798 .replace('\\', "/");
3799
3800 let content = match std::fs::read_to_string(path) {
3801 Ok(c) => c,
3802 Err(_) => continue,
3803 };
3804
3805 let lines: Vec<&str> = content.lines().collect();
3806 let mut in_drop_impl = false;
3807 let mut drop_type_name = String::new();
3808 let mut brace_depth = 0;
3809 let mut drop_start_depth = 0;
3810
3811 for (idx, line) in lines.iter().enumerate() {
3812 let trimmed = line.trim();
3813
3814 if trimmed.starts_with("//") {
3816 continue;
3817 }
3818
3819 if trimmed.contains("impl") && trimmed.contains("Drop") && trimmed.contains("for") {
3821 in_drop_impl = true;
3822 drop_start_depth = brace_depth;
3823 if let Some(for_pos) = trimmed.find("for ") {
3825 let after_for = &trimmed[for_pos + 4..];
3826 drop_type_name = after_for
3827 .split(|c: char| c == '<' || c == '{' || c.is_whitespace())
3828 .next()
3829 .unwrap_or("")
3830 .trim()
3831 .to_string();
3832 }
3833 }
3834
3835 brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
3837 brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;
3838
3839 if in_drop_impl && brace_depth <= drop_start_depth && trimmed.contains('}') {
3841 in_drop_impl = false;
3842 drop_type_name.clear();
3843 }
3844
3845 if in_drop_impl {
3847 for (pattern, advice) in Self::panic_patterns() {
3848 if trimmed.contains(pattern) {
3849 if let Some(comment_pos) = trimmed.find("//") {
3852 if trimmed
3853 .find(pattern)
3854 .map(|p| p > comment_pos)
3855 .unwrap_or(false)
3856 {
3857 continue;
3858 }
3859 }
3860
3861 if *pattern == "[" {
3863 if !trimmed.contains("][")
3864 && !trimmed.contains("[..]")
3865 && trimmed.contains("[")
3866 && trimmed.contains("]")
3867 && !trimmed.contains(".get(")
3868 {
3869 } else {
3871 continue;
3872 }
3873 }
3874
3875 let location = format!("{}:{}", rel_path, idx + 1);
3876 findings.push(Finding {
3877 rule_id: self.metadata.id.clone(),
3878 rule_name: self.metadata.name.clone(),
3879 severity: self.metadata.default_severity,
3880 message: format!(
3881 "Panic-prone code in Drop impl for '{}': {}. {}",
3882 drop_type_name, pattern, advice
3883 ),
3884 function: location,
3885 function_signature: format!("Drop for {}", drop_type_name),
3886 evidence: vec![trimmed.to_string()],
3887 span: None,
3888 ..Default::default()
3889 });
3890 break; }
3892 }
3893 }
3894 }
3895 }
3896
3897 findings
3898 }
3899}
3900
3901pub struct SpawnedTaskPanicRule {
3908 metadata: RuleMetadata,
3909}
3910
3911impl SpawnedTaskPanicRule {
3912 pub fn new() -> Self {
3913 Self {
3914 metadata: RuleMetadata {
3915 id: "RUSTCOLA125".to_string(),
3916 name: "spawned-task-panic-propagation".to_string(),
3917 short_description: "Spawned task may silently swallow panics".to_string(),
3918 full_description: "Detects spawned tasks (tokio::spawn, async_std::spawn, etc.) \
3919 without panic handling. By default, panics in spawned tasks are silently \
3920 swallowed when the JoinHandle is dropped. Use .await on JoinHandle, \
3921 catch_unwind, or panic hooks to detect task panics."
3922 .to_string(),
3923 help_uri: Some("https://docs.rs/tokio/latest/tokio/task/fn.spawn.html".to_string()),
3924 default_severity: Severity::Medium,
3925 origin: RuleOrigin::BuiltIn,
3926 cwe_ids: Vec::new(),
3927 fix_suggestion: None,
3928 exploitability: Exploitability::default(),
3929 },
3930 }
3931 }
3932
3933 fn spawn_functions() -> &'static [&'static str] {
3935 &[
3936 "tokio::spawn(",
3937 "spawn(",
3938 "task::spawn(",
3939 "async_std::spawn(",
3940 "spawn_blocking(",
3941 "spawn_local(",
3942 "thread::spawn(",
3943 "rayon::spawn(",
3944 ]
3945 }
3946
3947 fn panic_handling_patterns() -> &'static [&'static str] {
3949 &[
3950 ".await",
3951 "join!(",
3952 "try_join!(",
3953 "JoinSet",
3954 "catch_unwind",
3955 "AssertUnwindSafe",
3956 "panic::set_hook",
3957 ".abort()",
3958 "JoinHandle",
3959 "let handle =",
3960 "let _ = handle",
3961 ]
3962 }
3963}
3964
3965impl Rule for SpawnedTaskPanicRule {
3966 fn metadata(&self) -> &RuleMetadata {
3967 &self.metadata
3968 }
3969
3970 fn evaluate(
3971 &self,
3972 package: &MirPackage,
3973 _inter_analysis: Option<&crate::interprocedural::InterProceduralAnalysis>,
3974 ) -> Vec<Finding> {
3975 if package.crate_name == "mir-extractor" {
3976 return Vec::new();
3977 }
3978
3979 let mut findings = Vec::new();
3980 let crate_root = Path::new(&package.crate_root);
3981
3982 if !crate_root.exists() {
3983 return findings;
3984 }
3985
3986 for entry in WalkDir::new(crate_root)
3987 .into_iter()
3988 .filter_entry(|e| filter_entry(e))
3989 {
3990 let entry = match entry {
3991 Ok(e) => e,
3992 Err(_) => continue,
3993 };
3994
3995 if !entry.file_type().is_file() {
3996 continue;
3997 }
3998
3999 let path = entry.path();
4000 if path.extension() != Some(std::ffi::OsStr::new("rs")) {
4001 continue;
4002 }
4003
4004 let rel_path = path
4005 .strip_prefix(crate_root)
4006 .unwrap_or(path)
4007 .to_string_lossy()
4008 .replace('\\', "/");
4009
4010 let content = match std::fs::read_to_string(path) {
4011 Ok(c) => c,
4012 Err(_) => continue,
4013 };
4014
4015 let lines: Vec<&str> = content.lines().collect();
4016
4017 for (idx, line) in lines.iter().enumerate() {
4018 let trimmed = line.trim();
4019
4020 if trimmed.starts_with("//") || trimmed.contains("#[test]") {
4022 continue;
4023 }
4024
4025 for spawn_fn in Self::spawn_functions() {
4027 if trimmed.contains(spawn_fn) {
4028 let has_panic_handling = {
4030 let check_range = idx..std::cmp::min(idx + 6, lines.len());
4032 let context: String = lines[check_range]
4033 .iter()
4034 .map(|s| *s)
4035 .collect::<Vec<&str>>()
4036 .join("\n");
4037
4038 Self::panic_handling_patterns()
4039 .iter()
4040 .any(|pattern| context.contains(pattern))
4041 };
4042
4043 let is_assigned = trimmed.contains("let ")
4045 && trimmed.contains(" = ")
4046 && !trimmed.contains("let _ =");
4047
4048 if !has_panic_handling && !is_assigned {
4049 let location = format!("{}:{}", rel_path, idx + 1);
4050 findings.push(Finding::new(self.metadata.id.clone(), self.metadata.name.clone(), self.metadata.default_severity, format!(
4051 "Spawned task without panic handling. Panics will be silently swallowed. \
4052 Consider using .await on the JoinHandle or adding a panic hook."
4053 ), location, String::new(), vec![trimmed.to_string()], None));
4054 }
4055 break;
4056 }
4057 }
4058 }
4059 }
4060
4061 findings
4062 }
4063}
4064
4065pub fn register_concurrency_rules(engine: &mut crate::RuleEngine) {
4071 engine.register_rule(Box::new(NonThreadSafeTestRule::new()));
4072 engine.register_rule(Box::new(BlockingSleepInAsyncRule::new()));
4073 engine.register_rule(Box::new(BlockingOpsInAsyncRule::new()));
4074 engine.register_rule(Box::new(MutexGuardAcrossAwaitRule::new()));
4075 engine.register_rule(Box::new(UnderscoreLockGuardRule::new()));
4076 engine.register_rule(Box::new(BroadcastUnsyncPayloadRule::new()));
4077 engine.register_rule(Box::new(PanicInDropRule::new()));
4078 engine.register_rule(Box::new(UnwrapInPollRule::new()));
4079 engine.register_rule(Box::new(UnsafeSendSyncBoundsRule::new()));
4080 engine.register_rule(Box::new(NonCancellationSafeSelectRule::new()));
4081 engine.register_rule(Box::new(MissingSyncBoundOnCloneRule::new()));
4082 engine.register_rule(Box::new(PinContractViolationRule::new()));
4083 engine.register_rule(Box::new(OneshotRaceAfterCloseRule::new()));
4084 engine.register_rule(Box::new(AsyncSignalUnsafeInHandlerRule::new()));
4085 engine.register_rule(Box::new(OnceCellTocTouRule::new()));
4086 engine.register_rule(Box::new(PanicWhileHoldingLockRule::new()));
4087 engine.register_rule(Box::new(ClosureEscapingRefsRule::new()));
4088 engine.register_rule(Box::new(ExecutorStarvationRule::new()));
4089 engine.register_rule(Box::new(AsyncDropCorrectnessRule::new()));
4090 engine.register_rule(Box::new(PanicInDropImplRule::new()));
4091 engine.register_rule(Box::new(SpawnedTaskPanicRule::new()));
4092}