use super::cache::GuardCache;
use super::compile::Compiled;
use super::range::{Resolved, Source, read};
use crate::check::finding::{Finding, Severity};
use crate::config::{Config, GuardDef};
use crate::workspace::vcs;
use anyhow::Result;
use rayon::prelude::*;
use std::io::Read;
const MAX_INFLATED: u64 = 16 * 1024 * 1024;
const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
fn severity(g: &GuardDef) -> Severity {
if g.level == "warn" { Severity::Warn } else { Severity::Error }
}
fn hit(g: &GuardDef, path: &str, line: usize, message: String) -> Finding {
Finding::new("U1", severity(g), path, line, format!("[{}] {message}", g.name))
}
fn scan_text(g: &GuardDef, c: &Compiled, path: &str, text: &str, out: &mut Vec<Finding>) {
for (line, message) in c.text_hits(g, path, text) {
out.push(hit(g, path, line, message));
}
}
fn inflate(bytes: &[u8]) -> Option<Vec<u8>> {
let mut out = vec![];
flate2::read::GzDecoder::new(bytes).take(MAX_INFLATED).read_to_end(&mut out).ok()?;
Some(out)
}
fn as_text(g: &GuardDef, bytes: &[u8]) -> Option<String> {
if let Ok(s) = std::str::from_utf8(bytes)
&& !s.contains('\0')
{
return Some(s.to_string());
}
if !g.read_binary {
return None;
}
Some(bytes.iter().map(|&b| b as char).collect())
}
pub struct Scanned {
pub findings: Vec<Finding>,
pub files: Vec<String>,
}
fn scan_file(cfg: &Config, g: &GuardDef, c: &Compiled, range: &Resolved, path: &str) -> Vec<Finding> {
let mut out = vec![];
if c.denies_file(path) {
out.push(hit(g, path, 1, "this file may not be here".into()));
}
for m in c.path_hits(path) {
out.push(hit(g, path, 1, m));
}
let Some(bytes) = read(&cfg.root, &range.source, path) else { return out };
out.extend(scan_bytes(g, c, path, bytes));
out
}
pub fn scan_bytes(g: &GuardDef, c: &Compiled, path: &str, mut bytes: Vec<u8>) -> Vec<Finding> {
let mut out = vec![];
if let Some(max) = g.max_file_bytes
&& bytes.len() as u64 > max
{
out.push(hit(g, path, 1, format!("{} bytes is over {max}", bytes.len())));
}
if g.decompress && bytes.starts_with(&GZIP_MAGIC) {
match inflate(&bytes) {
Some(b) => bytes = b,
None => out.push(hit(g, path, 1, "cannot be decompressed".into())),
}
}
if let Some(text) = as_text(g, &bytes) {
scan_text(g, c, path, &text, &mut out);
}
out
}
pub fn scan_blob(g: &GuardDef, c: &Compiled, label: &str, bytes: Vec<u8>) -> Vec<Finding> {
let mut out = vec![];
if c.denies_file(label) {
out.push(hit(g, label, 1, "this file may not be here".into()));
}
for m in c.path_hits(label) {
out.push(hit(g, label, 1, m));
}
out.extend(scan_bytes(g, c, label, bytes));
out
}
fn workers() -> rayon::ThreadPool {
let half = std::thread::available_parallelism().map(|n| (n.get() / 2).max(1)).unwrap_or(1);
let n = std::env::var("RAYON_NUM_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(half);
rayon::ThreadPoolBuilder::new().num_threads(n).build().unwrap_or_else(|_| {
rayon::ThreadPoolBuilder::new().num_threads(1).build().expect("a one-thread pool always builds")
})
}
fn passing_hits(g: &GuardDef, c: &Compiled, range: &Resolved, out: &mut Vec<Finding>) {
let mut seen = std::collections::HashSet::new();
for p in range.passing.iter().filter(|p| p.path != "spec.toml" && c.in_scope(&p.path)) {
let short = &p.commit[..p.commit.len().min(10)];
for mut f in scan_bytes(g, c, &p.path, p.bytes.clone()) {
let known = out.iter().any(|o| o.path == f.path && o.message == f.message);
if known || !seen.insert((f.path.clone(), f.message.clone())) {
continue;
}
f.message = format!("{} (in commit {short}; still pushed though a later commit removes it)", f.message);
out.push(f);
}
}
}
pub fn run(cfg: &Config, g: &GuardDef, c: &Compiled, range: &Resolved) -> Result<Scanned> {
let mut out = vec![];
let mut files = vec![];
for p in &c.missing {
out.push(hit(g, p, 1, "a published path is not present, so nothing reads it".into()));
}
let whole_tree = matches!(range.source, Source::WorkTree);
let mut cache = whole_tree.then(|| GuardCache::open(&cfg.root, g, c.terms_source()));
let mut misses = vec![];
for path in range.files.iter().filter(|p| p.as_str() != "spec.toml" && c.in_scope(p)) {
files.push(path.clone());
match cache.as_mut().and_then(|k| k.lookup(&cfg.root, path)) {
Some(found) => out.extend(found),
None => misses.push(path.as_str()),
}
}
let scanned: Vec<(&str, Vec<Finding>)> =
workers().install(|| misses.par_iter().map(|p| (*p, scan_file(cfg, g, c, range, p))).collect());
for (path, found) in scanned {
if let Some(k) = cache.as_mut() {
k.put(&cfg.root, path, &found);
}
out.extend(found);
}
if let Some(k) = cache {
k.save();
}
passing_hits(g, c, range, &mut out);
if g.commit_messages
&& let Some(r) = &range.commits
{
for commit in vcs::commits(&cfg.root, r).unwrap_or_default() {
let label = format!("commit {}", &commit.hash[..commit.hash.len().min(10)]);
scan_text(g, c, &label, &format!("{}\n{}", commit.author, commit.message), &mut out);
}
}
if g.fail_if_empty && matches!(range.source, Source::WorkTree) {
if files.is_empty() {
out.push(hit(g, "spec.toml", 1, "scanned no file in its paths; a gate over nothing proves nothing (fix the paths, or set fail_if_empty = false until the first file exists)".into()));
}
for p in c.published.iter().filter(|p| !files.iter().any(|f| f.starts_with(&format!("{p}/")))) {
out.push(hit(g, p, 1, "a published path yielded no file; is it checked out?".into()));
}
}
Ok(Scanned { findings: out, files })
}