rscheck_cli/rules/absolute_filesystem_paths/
mod.rs1use crate::analysis::Workspace;
2use crate::config::AbsoluteFilesystemPathsConfig;
3use crate::emit::Emitter;
4use crate::report::{
5 Finding, FindingLabel, FindingLabelKind, FindingNote, FindingNoteKind, Severity,
6};
7use crate::rules::{Rule, RuleBackend, RuleContext, RuleFamily, RuleInfo};
8use crate::span::{Location, Span};
9use globset::{Glob, GlobSet, GlobSetBuilder};
10use regex::RegexSet;
11use std::iter;
12use std::path::Path;
13use syn::visit::Visit;
14
15pub struct AbsoluteFilesystemPathsRule;
16
17impl AbsoluteFilesystemPathsRule {
18 pub fn static_info() -> RuleInfo {
19 RuleInfo {
20 id: "portability.absolute_literal_paths",
21 family: RuleFamily::Portability,
22 backend: RuleBackend::Syntax,
23 summary: "Flags absolute filesystem paths inside string literals (Unix/Windows/UNC).",
24 default_level: AbsoluteFilesystemPathsConfig::default().level,
25 schema: "level, allow_globs, allow_regex, check_comments",
26 config_example: "[rules.\"portability.absolute_literal_paths\"]\nlevel = \"warn\"\ncheck_comments = false",
27 fixable: false,
28 }
29 }
30}
31
32impl Rule for AbsoluteFilesystemPathsRule {
33 fn info(&self) -> RuleInfo {
34 Self::static_info()
35 }
36
37 fn run(&self, ws: &Workspace, ctx: &RuleContext<'_>, out: &mut dyn Emitter) {
38 for file in &ws.files {
39 let cfg = match ctx.policy.decode_rule::<AbsoluteFilesystemPathsConfig>(
40 Self::static_info().id,
41 Some(&file.path),
42 ) {
43 Ok(cfg) => cfg,
44 Err(_) => continue,
45 };
46 let Some(ast) = &file.ast else { continue };
47 let allow_globs = build_allow_globs(&cfg.allow_globs);
48 let allow_regex = build_allow_regex(&cfg.allow_regex);
49 let mut v = Visitor {
50 file_path: &file.path,
51 allow_globs: &allow_globs,
52 allow_regex: &allow_regex,
53 severity: cfg.level.to_severity(),
54 out,
55 };
56 v.visit_file(ast);
57
58 if cfg.check_comments {
59 scan_line_comments(
60 &file.path,
61 &file.text,
62 &allow_globs,
63 &allow_regex,
64 cfg.level.to_severity(),
65 out,
66 );
67 }
68 }
69 }
70}
71
72struct Visitor<'a> {
73 file_path: &'a Path,
74 allow_globs: &'a GlobSet,
75 allow_regex: &'a RegexSet,
76 severity: Severity,
77 out: &'a mut dyn Emitter,
78}
79
80impl Visitor<'_> {
81 fn allowed(&self, value: &str) -> bool {
82 self.allow_globs.is_match(value) || self.allow_regex.is_match(value)
83 }
84
85 fn check_str(&mut self, span: proc_macro2::Span, value: &str) {
86 let Some(kind) = absolute_kind(value) else {
87 return;
88 };
89 if self.allowed(value) {
90 return;
91 }
92 self.out.emit(Finding {
93 rule_id: AbsoluteFilesystemPathsRule::static_info().id.to_string(),
94 family: Some(AbsoluteFilesystemPathsRule::static_info().family),
95 engine: Some(AbsoluteFilesystemPathsRule::static_info().backend),
96 severity: self.severity,
97 message: format!("absolute filesystem path ({kind}): {value}"),
98 primary: Some(Span::from_pm_span(self.file_path, span)),
99 secondary: Vec::new(),
100 help: Some(
101 "Prefer relative paths or build paths via `PathBuf` at runtime.".to_string(),
102 ),
103 evidence: None,
104 confidence: None,
105 tags: vec!["paths".to_string()],
106 labels: vec![FindingLabel {
107 kind: FindingLabelKind::Primary,
108 span: Span::from_pm_span(self.file_path, span),
109 message: Some(format!("{kind} path literal")),
110 }],
111 notes: vec![FindingNote {
112 kind: FindingNoteKind::Help,
113 message: "Prefer relative paths or build paths via `PathBuf` at runtime."
114 .to_string(),
115 }],
116 fixes: Vec::new(),
117 });
118 }
119}
120
121impl<'ast> Visit<'ast> for Visitor<'_> {
122 fn visit_lit_str(&mut self, node: &'ast syn::LitStr) {
123 self.check_str(node.span(), &node.value());
124 syn::visit::visit_lit_str(self, node);
125 }
126}
127
128fn absolute_kind(s: &str) -> Option<&'static str> {
129 if s.starts_with('/') {
130 if is_non_filesystem_unix_literal(s) {
131 return None;
132 }
133 return Some("unix");
134 }
135 if s.len() >= 3 {
136 let bytes = s.as_bytes();
137 if bytes[1] == b':'
138 && (bytes[2] == b'\\' || bytes[2] == b'/')
139 && bytes[0].is_ascii_alphabetic()
140 {
141 return Some("windows-drive");
142 }
143 }
144 if s.starts_with(r"\\") {
145 let rest = s.trim_start_matches('\\');
146 let segments = rest.split('\\').filter(|p| !p.is_empty()).take(2).count();
147 if segments >= 2 {
148 return Some("unc");
149 }
150 return None;
151 }
152 None
153}
154
155fn is_non_filesystem_unix_literal(value: &str) -> bool {
156 if value.starts_with("//") || value.starts_with("/*") {
157 return true;
158 }
159 if value.trim_start_matches('/').is_empty() {
160 return true;
161 }
162 if value.contains("://")
163 || value.starts_with("/api/")
164 || value.starts_with("/graphql")
165 || value.starts_with("/oauth/")
166 || value.starts_with("/v1/")
167 || value.starts_with("/v2/")
168 || value.starts_with("/:")
169 {
170 return true;
171 }
172 if value.contains("/{")
173 || value.contains("/:")
174 || value.contains('?')
175 || value.contains('#')
176 || value.starts_with("^/")
177 || value.ends_with("/$")
178 {
179 return true;
180 }
181
182 false
183}
184
185fn scan_line_comments(
186 file_path: &Path,
187 text: &str,
188 allow_globs: &GlobSet,
189 allow_regex: &RegexSet,
190 severity: Severity,
191 out: &mut dyn Emitter,
192) {
193 for (idx, line) in text.lines().enumerate() {
194 let trimmed = line.trim_start();
195 if !trimmed.starts_with("//") {
196 continue;
197 }
198 for part in trimmed.split_whitespace() {
199 let candidate = trim_comment_punctuation(part);
200 if absolute_kind(candidate).is_none() {
201 continue;
202 }
203 if allow_globs.is_match(candidate) || allow_regex.is_match(candidate) {
204 continue;
205 }
206 out.emit(Finding {
207 rule_id: AbsoluteFilesystemPathsRule::static_info().id.to_string(),
208 family: Some(AbsoluteFilesystemPathsRule::static_info().family),
209 engine: Some(AbsoluteFilesystemPathsRule::static_info().backend),
210 severity,
211 message: format!("absolute filesystem path in comment: {candidate}"),
212 primary: Some(Span::new(
213 file_path,
214 Location {
215 line: (idx as u32).saturating_add(1),
216 column: 1,
217 },
218 Location {
219 line: (idx as u32).saturating_add(1),
220 column: 1,
221 },
222 )),
223 secondary: Vec::new(),
224 help: None,
225 evidence: None,
226 confidence: None,
227 tags: vec!["paths".to_string(), "comments".to_string()],
228 labels: Vec::new(),
229 notes: Vec::new(),
230 fixes: Vec::new(),
231 });
232 }
233 }
234}
235
236fn trim_comment_punctuation(part: &str) -> &str {
237 part.trim_matches(|ch: char| matches!(ch, '`' | '"' | '\'' | ',' | '.' | ';' | ')' | '('))
238 .trim_end_matches(':')
239}
240
241fn build_allow_globs(patterns: &[String]) -> GlobSet {
242 let mut b = GlobSetBuilder::new();
243 for p in patterns {
244 if let Ok(glob) = Glob::new(p) {
245 b.add(glob);
246 }
247 }
248 b.build()
249 .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
250}
251
252fn build_allow_regex(patterns: &[String]) -> RegexSet {
253 RegexSet::new(patterns.iter().map(String::as_str))
254 .unwrap_or_else(|_| RegexSet::new(iter::empty::<&'static str>()).unwrap())
255}
256
257#[cfg(test)]
258mod tests;