pub mod contract;
pub mod facts;
pub mod lints;
pub mod output;
pub mod suppress;
use crate::check::contract::{Confidence, Finding, ParsedFile, Severity, Span};
use crate::check::suppress::Suppressions;
use crate::config;
use anyhow::Result;
use std::path::{Path, PathBuf};
const NO_HANDLERS: &str = "NO-HANDLERS";
const UNTRACKED_ACCOUNTS: &str = "UNTRACKED-ACCOUNTS";
pub struct CheckOptions {
pub json: bool,
pub deny: Vec<String>,
pub allow: Vec<String>,
}
pub(crate) struct EffectiveConfig {
pub deny: Vec<String>,
pub warn: Vec<String>,
pub allow: Vec<String>,
pub cli_deny: Vec<String>,
pub cli_allow: Vec<String>,
pub threshold: Confidence,
pub authority_names: Vec<String>,
}
enum Disposition {
Allow,
Deny,
Warn,
Default,
}
fn resolve(cfg: &EffectiveConfig, code: &str) -> Disposition {
if code_matches(&cfg.cli_allow, code) {
Disposition::Allow
} else if code_matches(&cfg.cli_deny, code) {
Disposition::Deny
} else if code_matches(&cfg.allow, code) {
Disposition::Allow
} else if code_matches(&cfg.deny, code) {
Disposition::Deny
} else if code_matches(&cfg.warn, code) {
Disposition::Warn
} else {
Disposition::Default
}
}
pub fn run(opts: CheckOptions) -> Result<i32> {
let cfg = load_effective_config(&opts)?;
let lints = lints::registry();
let mut known: Vec<&'static str> = lints.iter().map(|l| l.code()).collect();
known.extend([NO_HANDLERS, UNTRACKED_ACCOUNTS]);
reject_unknown_codes(&cfg, &known)?;
let src_dir = Path::new("src");
let mut files = Vec::new();
if src_dir.exists() {
collect_rs_files(src_dir, &mut files)?;
}
let mut supp = Suppressions::default();
let mut parsed = Vec::new();
for path in &files {
let src = std::fs::read_to_string(path)?;
supp.scan(&path.display().to_string(), &src);
let Ok(ast) = syn::parse_file(&src) else {
continue;
};
parsed.push(ParsedFile {
path: path.clone(),
src,
ast,
});
}
let handlers = facts::analyze(&parsed, &cfg.authority_names);
let mut by_lint: Vec<Vec<Finding>> = lints
.iter()
.map(|l| {
let mut seen = std::collections::HashSet::new();
l.run_handlers(&handlers)
.into_iter()
.filter(|f| seen.insert((f.span.file.clone(), f.span.line, f.span.col)))
.collect()
})
.collect();
let mut raw = Vec::new();
for file in &parsed {
let name = file.path.display().to_string();
for (lint, crate_wide) in lints.iter().zip(&mut by_lint) {
raw.extend(lint.run(file));
let (here, rest) = std::mem::take(crate_wide)
.into_iter()
.partition(|f: &Finding| f.span.file == name);
raw.extend::<Vec<Finding>>(here);
*crate_wide = rest;
}
}
let mut storing: Vec<(String, Span)> = Vec::new();
let mut listed = std::collections::HashSet::new();
for h in &handlers {
let entry = if h.unbound_accounts {
let span = Span {
file: h.file.clone(),
line: 0,
col: 0,
};
Some((
format!("`{}` (binds no account from its slice)", h.name),
span,
))
} else {
h.stores_accounts
.as_ref()
.filter(|_| h.followed_by.is_empty())
.map(|site| {
(
format!("`{}` (stores them where nothing reads them back)", h.name),
site.to_span(),
)
})
};
if let Some(entry) = entry {
if listed.insert((h.file.clone(), h.name.clone())) {
storing.push(entry);
}
}
}
storing.sort_by(|a, b| (&a.1.file, a.1.line).cmp(&(&b.1.file, b.1.line)));
raw.extend(coverage_findings(
src_dir.exists(),
files.len(),
handlers.len(),
&storing,
));
let processed = process_findings(raw, &cfg, &mut supp);
if opts.json {
output::render_json(&processed.findings)?;
} else {
output::render_human(
&processed.findings,
&processed.below_threshold,
cfg.threshold,
);
}
Ok(processed.exit_code)
}
fn coverage_findings(
src_found: bool,
file_count: usize,
handler_count: usize,
storing: &[(String, Span)],
) -> Vec<Finding> {
let mut out = Vec::new();
if handler_count == 0 {
let (evidence, fix) = if src_found {
(
format!(
"no instruction handlers found in {file_count} source file(s): no function or method takes an `&[AccountView]`/`&[AccountInfo]` slice, so the account and CPI lints (ACC*, CPI*, ZC002-P) analysed nothing. Only the struct-layout lints (ZC001-P, ZC003-P) ran. This is not a clean result"
),
"if this crate is not a program, allow `NO-HANDLERS`; otherwise its handlers take their accounts in a form `pinoc check` does not follow",
)
} else {
(
"no `src/` directory in the current directory, so nothing was analysed. This is not a clean result".to_string(),
"run `pinoc check` from the program crate's root",
)
};
out.push(Finding {
code: NO_HANDLERS,
id: "no-handlers",
confidence: Confidence::Definite,
severity: Severity::Warn,
span: Span {
file: "src".to_string(),
line: 0,
col: 0,
},
evidence,
fix: Some(fix.to_string()),
});
}
if !storing.is_empty() {
const SHOWN: usize = 5;
let mut names = storing
.iter()
.take(SHOWN)
.map(|(name, _)| name.clone())
.collect::<Vec<_>>()
.join(", ");
if storing.len() > SHOWN {
names.push_str(&format!(", and {} more", storing.len() - SHOWN));
}
out.push(Finding {
code: UNTRACKED_ACCOUNTS,
id: "untracked-accounts",
confidence: Confidence::Definite,
severity: Severity::Warn,
span: Span {
file: "src".to_string(),
line: 0,
col: 0,
},
evidence: format!(
"{} handler(s) have accounts that were not analysed: {names}. Checks and uses pinoc could not attach to an account are not reported, so this is not a clean result for them",
storing.len()
),
fix: Some(
"no code change needed; allow `UNTRACKED-ACCOUNTS` once the limit is acknowledged"
.to_string(),
),
});
}
out
}
pub(crate) struct Processed {
pub findings: Vec<Finding>,
pub exit_code: i32,
pub below_threshold: Vec<&'static str>,
}
pub(crate) fn process_findings(
raw: Vec<Finding>,
cfg: &EffectiveConfig,
supp: &mut Suppressions,
) -> Processed {
let mut out = Vec::new();
let mut below_threshold = Vec::new();
for mut f in raw {
let denied = match resolve(cfg, f.code) {
Disposition::Allow => continue,
Disposition::Deny => {
f.severity = Severity::Deny;
true
}
Disposition::Warn => {
f.severity = Severity::Warn;
false
}
Disposition::Default => false,
};
if supp.is_suppressed(&f.span.file, f.span.line, f.code) {
continue;
}
if f.confidence < cfg.threshold && !denied {
below_threshold.push(f.code);
continue;
}
out.push(f);
}
for a in supp.unused() {
out.push(Finding {
code: "UNUSED-ALLOW",
id: "unused-allow",
confidence: Confidence::Definite,
severity: Severity::Warn,
span: Span {
file: a.file.clone(),
line: a.line,
col: 0,
},
evidence: format!("`pinoc:allow({})` matched no finding", a.code),
fix: None,
});
}
let exit_code = i32::from(out.iter().any(|f| f.severity == Severity::Deny));
Processed {
findings: out,
exit_code,
below_threshold,
}
}
fn load_effective_config(opts: &CheckOptions) -> Result<EffectiveConfig> {
let check = config::read_pinoc_config_optional()?
.map(|c| c.check)
.unwrap_or_default();
Ok(EffectiveConfig {
deny: check.deny,
warn: check.warn,
allow: check.allow,
cli_deny: opts.deny.clone(),
cli_allow: opts.allow.clone(),
threshold: parse_confidence(check.confidence_threshold.as_deref())?,
authority_names: check.authority_names,
})
}
fn code_matches(list: &[String], code: &str) -> bool {
list.iter().any(|c| is_wildcard(c) || c == code)
}
fn is_wildcard(code: &str) -> bool {
code == "*" || code == "all"
}
fn reject_unknown_codes(cfg: &EffectiveConfig, known: &[&'static str]) -> Result<()> {
let lists = [
(&cfg.deny, "`deny` under `[check]` in Pinoc.toml"),
(&cfg.warn, "`warn` under `[check]` in Pinoc.toml"),
(&cfg.allow, "`allow` under `[check]` in Pinoc.toml"),
(&cfg.cli_deny, "`--deny`"),
(&cfg.cli_allow, "`--allow`"),
];
for (list, from) in lists {
for code in list {
if !is_wildcard(code) && !known.contains(&code.as_str()) {
anyhow::bail!(
"`{code}`, given to {from}, is not a lint code. The codes are {}; `all` or `'*'` means every code (quote `*` so the shell does not expand it into filenames).",
known.join(", ")
);
}
}
}
Ok(())
}
fn parse_confidence(s: Option<&str>) -> Result<Confidence> {
match s.map(str::to_ascii_lowercase).as_deref() {
None => Ok(Confidence::Likely),
Some("heuristic") => Ok(Confidence::Heuristic),
Some("likely") => Ok(Confidence::Likely),
Some("definite") => Ok(Confidence::Definite),
Some(_) => anyhow::bail!(
"`confidence_threshold = \"{}\"` under `[check]` in Pinoc.toml is not a confidence level; use `heuristic`, `likely` or `definite`",
s.unwrap_or_default()
),
}
}
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.is_dir() {
collect_rs_files(&path, out)?;
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
out.push(path);
}
}
Ok(())
}