Skip to main content

rscheck_cli/rules/absolute_filesystem_paths/
mod.rs

1use 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;