rscheck-cli 0.1.0-alpha.3

CLI frontend for the rscheck policy engine.
Documentation
use crate::analysis::Workspace;
use crate::config::AbsoluteFilesystemPathsConfig;
use crate::emit::Emitter;
use crate::report::{
    Finding, FindingLabel, FindingLabelKind, FindingNote, FindingNoteKind, Severity,
};
use crate::rules::{Rule, RuleBackend, RuleContext, RuleFamily, RuleInfo};
use crate::span::{Location, Span};
use globset::{Glob, GlobSet, GlobSetBuilder};
use regex::RegexSet;
use std::iter;
use std::path::Path;
use syn::visit::Visit;

pub struct AbsoluteFilesystemPathsRule;

impl AbsoluteFilesystemPathsRule {
    pub fn static_info() -> RuleInfo {
        RuleInfo {
            id: "portability.absolute_literal_paths",
            family: RuleFamily::Portability,
            backend: RuleBackend::Syntax,
            summary: "Flags absolute filesystem paths inside string literals (Unix/Windows/UNC).",
            default_level: AbsoluteFilesystemPathsConfig::default().level,
            schema: "level, allow_globs, allow_regex, check_comments",
            config_example: "[rules.\"portability.absolute_literal_paths\"]\nlevel = \"warn\"\ncheck_comments = false",
            fixable: false,
        }
    }
}

impl Rule for AbsoluteFilesystemPathsRule {
    fn info(&self) -> RuleInfo {
        Self::static_info()
    }

    fn run(&self, ws: &Workspace, ctx: &RuleContext<'_>, out: &mut dyn Emitter) {
        for file in &ws.files {
            let cfg = match ctx.policy.decode_rule::<AbsoluteFilesystemPathsConfig>(
                Self::static_info().id,
                Some(&file.path),
            ) {
                Ok(cfg) => cfg,
                Err(_) => continue,
            };
            let Some(ast) = &file.ast else { continue };
            let allow_globs = build_allow_globs(&cfg.allow_globs);
            let allow_regex = build_allow_regex(&cfg.allow_regex);
            let mut v = Visitor {
                file_path: &file.path,
                allow_globs: &allow_globs,
                allow_regex: &allow_regex,
                severity: cfg.level.to_severity(),
                out,
            };
            v.visit_file(ast);

            if cfg.check_comments {
                scan_line_comments(
                    &file.path,
                    &file.text,
                    &allow_globs,
                    &allow_regex,
                    cfg.level.to_severity(),
                    out,
                );
            }
        }
    }
}

struct Visitor<'a> {
    file_path: &'a Path,
    allow_globs: &'a GlobSet,
    allow_regex: &'a RegexSet,
    severity: Severity,
    out: &'a mut dyn Emitter,
}

impl Visitor<'_> {
    fn allowed(&self, value: &str) -> bool {
        self.allow_globs.is_match(value) || self.allow_regex.is_match(value)
    }

    fn check_str(&mut self, span: proc_macro2::Span, value: &str) {
        let Some(kind) = absolute_kind(value) else {
            return;
        };
        if self.allowed(value) {
            return;
        }
        self.out.emit(Finding {
            rule_id: AbsoluteFilesystemPathsRule::static_info().id.to_string(),
            family: Some(AbsoluteFilesystemPathsRule::static_info().family),
            engine: Some(AbsoluteFilesystemPathsRule::static_info().backend),
            severity: self.severity,
            message: format!("absolute filesystem path ({kind}): {value}"),
            primary: Some(Span::from_pm_span(self.file_path, span)),
            secondary: Vec::new(),
            help: Some(
                "Prefer relative paths or build paths via `PathBuf` at runtime.".to_string(),
            ),
            evidence: None,
            confidence: None,
            tags: vec!["paths".to_string()],
            labels: vec![FindingLabel {
                kind: FindingLabelKind::Primary,
                span: Span::from_pm_span(self.file_path, span),
                message: Some(format!("{kind} path literal")),
            }],
            notes: vec![FindingNote {
                kind: FindingNoteKind::Help,
                message: "Prefer relative paths or build paths via `PathBuf` at runtime."
                    .to_string(),
            }],
            fixes: Vec::new(),
        });
    }
}

impl<'ast> Visit<'ast> for Visitor<'_> {
    fn visit_lit_str(&mut self, node: &'ast syn::LitStr) {
        self.check_str(node.span(), &node.value());
        syn::visit::visit_lit_str(self, node);
    }
}

fn absolute_kind(s: &str) -> Option<&'static str> {
    if s.starts_with('/') {
        if is_non_filesystem_unix_literal(s) {
            return None;
        }
        return Some("unix");
    }
    if s.len() >= 3 {
        let bytes = s.as_bytes();
        if bytes[1] == b':'
            && (bytes[2] == b'\\' || bytes[2] == b'/')
            && bytes[0].is_ascii_alphabetic()
        {
            return Some("windows-drive");
        }
    }
    if s.starts_with(r"\\") {
        let rest = s.trim_start_matches('\\');
        let segments = rest.split('\\').filter(|p| !p.is_empty()).take(2).count();
        if segments >= 2 {
            return Some("unc");
        }
        return None;
    }
    None
}

fn is_non_filesystem_unix_literal(value: &str) -> bool {
    if value.starts_with("//") || value.starts_with("/*") {
        return true;
    }
    if value.trim_start_matches('/').is_empty() {
        return true;
    }
    if value.contains("://")
        || value.starts_with("/api/")
        || value.starts_with("/graphql")
        || value.starts_with("/oauth/")
        || value.starts_with("/v1/")
        || value.starts_with("/v2/")
        || value.starts_with("/:")
    {
        return true;
    }
    if value.contains("/{")
        || value.contains("/:")
        || value.contains('?')
        || value.contains('#')
        || value.starts_with("^/")
        || value.ends_with("/$")
    {
        return true;
    }

    false
}

fn scan_line_comments(
    file_path: &Path,
    text: &str,
    allow_globs: &GlobSet,
    allow_regex: &RegexSet,
    severity: Severity,
    out: &mut dyn Emitter,
) {
    for (idx, line) in text.lines().enumerate() {
        let trimmed = line.trim_start();
        if !trimmed.starts_with("//") {
            continue;
        }
        for part in trimmed.split_whitespace() {
            let candidate = trim_comment_punctuation(part);
            if absolute_kind(candidate).is_none() {
                continue;
            }
            if allow_globs.is_match(candidate) || allow_regex.is_match(candidate) {
                continue;
            }
            out.emit(Finding {
                rule_id: AbsoluteFilesystemPathsRule::static_info().id.to_string(),
                family: Some(AbsoluteFilesystemPathsRule::static_info().family),
                engine: Some(AbsoluteFilesystemPathsRule::static_info().backend),
                severity,
                message: format!("absolute filesystem path in comment: {candidate}"),
                primary: Some(Span::new(
                    file_path,
                    Location {
                        line: (idx as u32).saturating_add(1),
                        column: 1,
                    },
                    Location {
                        line: (idx as u32).saturating_add(1),
                        column: 1,
                    },
                )),
                secondary: Vec::new(),
                help: None,
                evidence: None,
                confidence: None,
                tags: vec!["paths".to_string(), "comments".to_string()],
                labels: Vec::new(),
                notes: Vec::new(),
                fixes: Vec::new(),
            });
        }
    }
}

fn trim_comment_punctuation(part: &str) -> &str {
    part.trim_matches(|ch: char| matches!(ch, '`' | '"' | '\'' | ',' | '.' | ';' | ')' | '('))
        .trim_end_matches(':')
}

fn build_allow_globs(patterns: &[String]) -> GlobSet {
    let mut b = GlobSetBuilder::new();
    for p in patterns {
        if let Ok(glob) = Glob::new(p) {
            b.add(glob);
        }
    }
    b.build()
        .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap())
}

fn build_allow_regex(patterns: &[String]) -> RegexSet {
    RegexSet::new(patterns.iter().map(String::as_str))
        .unwrap_or_else(|_| RegexSet::new(iter::empty::<&'static str>()).unwrap())
}

#[cfg(test)]
mod tests;