use std::collections::BTreeSet;
use std::path::Path;
use std::process::ExitCode;
use std::time::Instant;
use clap::Subcommand;
use fairlead_core::config::{self, LoadOptions};
use fairlead_guard::baseline::{self, Counts};
use fairlead_guard::events::{Event, EventLog};
use fairlead_guard::{added, git, stages, Finding, Guard, Source};
use rayon::prelude::*;
#[derive(Subcommand)]
pub enum GuardAction {
Check {
#[arg(long)]
staged: bool,
#[arg(long)]
list: bool,
#[arg(long, conflicts_with = "staged")]
write_baseline: bool,
#[arg(long, value_name = "REV", conflicts_with = "staged")]
base: Option<String>,
#[arg(long, conflicts_with_all = ["staged", "write_baseline", "base"])]
push: bool,
},
Hook,
Stop,
Nudge,
Compare {
file: String,
#[arg(long, value_delimiter = ',')]
rules: Vec<String>,
#[arg(long = "map", value_name = "THEIRS=OURS")]
maps: Vec<String>,
},
Bench {
#[arg(long, value_name = "REV")]
since: String,
#[arg(long, default_value_t = 200)]
limit: usize,
#[arg(long, value_name = "MS")]
p95_under: Option<f64>,
},
}
fn fail(message: impl std::fmt::Display) -> ExitCode {
eprintln!("guard: {message}");
ExitCode::FAILURE
}
pub fn run(action: GuardAction, sets: Vec<String>, cwd: &Path) -> ExitCode {
if matches!(action, GuardAction::Hook) {
return crate::hook_cmd::run();
}
if matches!(action, GuardAction::Stop) {
return crate::done_cmd::stop();
}
if matches!(action, GuardAction::Nudge) {
return crate::brief_cmd::nudge();
}
if let GuardAction::Bench {
since,
limit,
p95_under,
} = &action
{
return crate::bench_cmd::run(cwd, since, *limit, *p95_under);
}
let loaded = match config::load(cwd, &LoadOptions::from_process(sets)) {
Ok(loaded) => loaded,
Err(e) => return fail(e),
};
if !loaded.problems.is_empty() {
for p in &loaded.problems {
eprintln!("{}: {}", p.key, p.message);
}
return fail("the config has problems; run `fairlead config check`");
}
let root = if loaded.files.is_empty() {
crate::graph_cmd::repo_root(cwd)
} else {
loaded.root.clone()
};
let guard = match Guard::new(&loaded.config.guard, &root) {
Ok(guard) => guard,
Err(e) => return fail(e),
};
let staged_only = matches!(action, GuardAction::Check { staged: true, .. });
let push = matches!(action, GuardAction::Check { push: true, .. });
let nothing = if push {
guard.migrations.as_ref().is_none_or(|m| !m.fetches()) && loaded.config.pr.is_none()
} else {
guard.is_empty() && !(staged_only && guard.paths.is_some())
};
if nothing {
println!("guard: no rules configured");
return ExitCode::SUCCESS;
}
if let GuardAction::Compare { file, rules, maps } = &action {
return crate::bench_cmd::compare(&root, &guard, file, rules, maps);
}
let GuardAction::Check {
staged,
list,
write_baseline,
base,
push,
} = action
else {
unreachable!("the other actions returned above");
};
if push {
return check_push(&root, &guard, &loaded.config);
}
if staged {
return check_staged(&root, &guard, &loaded.config.guard, list);
}
let baseline_path = root.join(&loaded.config.guard.baseline);
let tree = Tree {
list,
write: write_baseline,
base: base.as_deref(),
};
check_tree(&root, &guard, &baseline_path, tree)
}
struct Tree<'a> {
list: bool,
write: bool,
base: Option<&'a str>,
}
pub fn tree_findings(
root: &Path,
guard: &Guard,
base: Option<&str>,
) -> Result<Vec<Finding>, String> {
tree_and_warnings(root, guard, base).map(|(found, _)| found)
}
fn tree_and_warnings(
root: &Path,
guard: &Guard,
base: Option<&str>,
) -> Result<(Vec<Finding>, Vec<Finding>), String> {
let files = git::tracked(root)?;
let mut findings: Vec<Finding> = files
.par_iter()
.filter(|p| guard.reads(p))
.map(|path| match std::fs::read_to_string(root.join(path)) {
Ok(text) => guard.lint(&Source::new(path, &text)),
Err(_) => Vec::new(),
})
.collect::<Vec<_>>()
.into_iter()
.flatten()
.collect();
let more = stages::tree(guard, root, &files, base)?;
findings.extend(more.findings);
for note in more.notes {
eprintln!("guard: {note}");
}
fairlead_guard::sort(&mut findings);
Ok((findings, more.warnings))
}
fn warn_all(warnings: &[Finding]) {
for w in warnings {
eprintln!("warning: {w}");
}
}
fn check_tree(root: &Path, guard: &Guard, baseline_path: &Path, how: Tree<'_>) -> ExitCode {
let Tree { list, write, base } = how;
let (findings, warnings) = match tree_and_warnings(root, guard, base) {
Ok(found) => found,
Err(e) => return fail(e),
};
warn_all(&warnings);
if list {
for f in &findings {
println!("{f}");
}
}
let ratcheted = guard.ratcheted();
let (held, zero): (Vec<&Finding>, Vec<&Finding>) =
findings.iter().partition(|f| ratcheted.contains(f.rule));
let now = baseline::tally(held.iter().copied());
if write {
return write_baseline(baseline_path, &now, held.len());
}
let was = match baseline::read(baseline_path) {
Ok(counts) => only_rules(counts, &ratcheted),
Err(e) => return fail(e),
};
let changes = baseline::changes(&now, &was);
let (worse, fell): (Vec<_>, Vec<_>) = changes.into_iter().partition(|c| c.now > c.was);
if !zero.is_empty() || !worse.is_empty() {
eprintln!(
"guard: {} finding(s) with no baseline, {} ratcheted file/rule pair(s) above the baseline",
zero.len(),
worse.len()
);
if !list {
let over: BTreeSet<(&str, &str)> = worse
.iter()
.map(|w| (w.file.as_str(), w.rule.as_str()))
.collect();
let shown = held
.iter()
.filter(|f| over.contains(&(f.file.as_str(), f.rule)));
for f in zero.iter().chain(shown) {
eprintln!("{f}");
}
}
for w in &worse {
eprintln!(
" {} {}: {} allowed, {} found",
w.file, w.rule, w.was, w.now
);
}
eprintln!("Fix them, or record a ratcheted count on purpose with `fairlead guard check --write-baseline`.");
return ExitCode::FAILURE;
}
println!(
"guard: clean, {} finding(s) held at the baseline",
held.len()
);
let mut by_rule = std::collections::BTreeMap::<&str, usize>::new();
for f in &held {
*by_rule.entry(f.rule).or_default() += 1;
}
for (rule, n) in by_rule {
println!(" {rule}: {n}");
}
if !fell.is_empty() {
println!(
"guard: {} ratcheted count(s) fell; `fairlead guard check --write-baseline` lowers the baseline to match",
fell.len()
);
}
ExitCode::SUCCESS
}
fn only_rules(counts: Counts, rules: &BTreeSet<&'static str>) -> Counts {
counts
.into_iter()
.map(|(file, by_rule)| {
let kept = by_rule
.into_iter()
.filter(|(r, _)| rules.contains(r.as_str()))
.collect();
(file, kept)
})
.filter(
|(_, by_rule): &(String, std::collections::BTreeMap<String, u64>)| !by_rule.is_empty(),
)
.collect()
}
fn write_baseline(path: &Path, counts: &Counts, total: usize) -> ExitCode {
if let Err(e) = baseline::write(path, counts) {
return fail(format!("{}: {e}", path.display()));
}
println!(
"guard: baseline written to {}, {total} finding(s) across {} file(s)",
path.display(),
counts.len()
);
ExitCode::SUCCESS
}
fn check_staged(root: &Path, guard: &Guard, settings: &config::Guard, list: bool) -> ExitCode {
let start = Instant::now();
let staged = match git::staged(root) {
Ok(staged) => staged,
Err(e) => return fail(e),
};
let mut new = Vec::new();
let mut read = 0;
for file in staged.iter().filter(|s| guard.reads(&s.path)) {
let Some(after) = git::in_index(root, &file.path) else {
continue;
};
read += 1;
let lint = |text: &str| guard.lint(&Source::new(&file.path, text));
let now = lint(&after);
if settings.findings == config::Findings::All {
new.extend(now);
continue;
}
let before = file
.before
.as_deref()
.and_then(|b| git::at_head(root, b))
.map(|text| lint(&text))
.unwrap_or_default();
new.extend(added::added(&before, &now));
}
match stages::staged(guard, root, &staged) {
Ok(more) => {
new.extend(more.findings);
for note in more.notes {
eprintln!("guard: {note}");
}
warn_all(&more.warnings);
}
Err(e) => return fail(e),
}
fairlead_guard::sort(&mut new);
let warn = settings.on_finding == config::OnFinding::Warn;
let decision = match (new.is_empty(), warn) {
(true, _) => "allow",
(false, true) => "warn",
(false, false) => "deny",
};
let mut event = Event::new("commit", decision, start.elapsed());
event.files = Some(read);
event.added = new.len();
event.rules = new
.iter()
.map(|f| f.rule)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let log = match settings.events {
config::Events::Local => EventLog::open(root),
config::Events::Off => None,
};
if let Some(log) = log {
let _ = log.append(&event);
}
if list {
for f in &new {
println!("{f}");
}
}
let what = match settings.findings {
config::Findings::Added => "the staged changes add",
config::Findings::All => "the staged files have",
};
if new.is_empty() {
println!("guard: {} staged file(s), {what} no findings", staged.len());
return ExitCode::SUCCESS;
}
let then = if warn { ", committing anyway" } else { "" };
eprintln!("guard: {what} {} finding(s){then}:", new.len());
if !list {
for f in &new {
eprintln!("{f}");
}
}
if warn {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn check_push(root: &Path, guard: &Guard, config: &config::Config) -> ExitCode {
let found = stages::push(guard, root);
for note in &found.notes {
eprintln!("guard: {note}");
}
let mut failed = !found.findings.is_empty();
if failed {
eprintln!(
"guard: {} migration number(s) already taken on the base:",
found.findings.len()
);
for f in &found.findings {
eprintln!("{f}");
}
}
if let Some(pr) = &config.pr {
failed |= !crate::pr_cmd::local(root, pr);
} else if !failed {
println!("guard: the branch's migration numbers are free on the base");
}
if failed {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
pub fn doctor_lines(root: &Path, config: &config::Config) -> String {
let ids: Vec<String> = config
.guard
.external
.items()
.iter()
.filter(|r| r.preset == Some(config::ExternalPreset::Gitleaks))
.map(|r| r.rule_id())
.collect();
if ids.is_empty() {
return String::new();
}
let rules = format!("`{}`", ids.join("`, `"));
use fairlead_guard::gitleaks;
match gitleaks::version(root) {
Some(v) if gitleaks::new_enough(&v) => format!("gitleaks: {v}, for {rules}\n"),
Some(v) => format!(
"gitleaks: {v} is older than {}.{}, so {rules} fails until it's upgraded\n",
gitleaks::OLDEST.0,
gitleaks::OLDEST.1
),
None => format!(
"gitleaks: not installed, so {rules} notes it and scans no content \
(https://github.com/gitleaks/gitleaks#installing)\n"
),
}
}