use crate::candidate_known_content::KnownContent;
use crate::candidate_version::CandidateVersion;
use crate::config_schema::{ForbiddenStringsOptions, PolicyConfig};
use crate::diagnostic_log::{debug, warn};
use crate::diagnostics::EngineFailureCode;
use crate::event_path::EventPath;
use crate::policy_content::{ContentState, LifecycleContent};
use crate::policy_engine::{PolicyFinding, PolicyOutcome};
use crate::repository_facts::RepositoryFacts;
use crate::repository_location::RepositoryLocation;
use crate::scanner_adapter::{CandidateScanner, RulesSource, ScannerError, ScannerFailure};
use crate::scanner_failure_code::{
finding_failure_code, scan_run_failure_code, scanner_failure_code,
};
use crate::scanner_selection::{RULES_VARIABLE, is_scannable, rules_candidate_paths, rules_source};
use crate::worktree_identity::worktree_root;
use forbidden_strings::{CandidateScan, RulesListError, ScanFinding};
use std::ffi::OsString;
use std::path::PathBuf;
use std::rc::Rc;
pub const FORBIDDEN_STRING_CODE: &str = "forbidden-string";
pub const ENGINE_ERROR_MESSAGE: &str = "The forbidden-strings scanner reported a matcher failure while scanning a candidate, so the scan is incomplete.";
pub const LINE_BREAK_MESSAGE: &str = "The forbidden-strings scanner cannot inspect a candidate pathname that contains a line break, so the scan is incomplete.";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScannerSettings {
pub options: ForbiddenStringsOptions,
pub rules_variable: Option<OsString>,
}
pub fn default_scanner_settings() -> ScannerSettings {
return ScannerSettings {
options: PolicyConfig::defaults().forbidden_strings,
rules_variable: None,
};
}
pub struct ScannerState {
loaded: Option<Result<CandidateScanner, ScannerError>>,
}
pub fn unloaded_scanner() -> ScannerState {
return ScannerState { loaded: None };
}
fn failed(code: EngineFailureCode, message: &str) -> PolicyOutcome {
return PolicyOutcome::Failed {
code,
message: String::from(message),
path: None,
};
}
fn incomplete_scan(scan: &CandidateScan) -> Option<PolicyOutcome> {
for finding in &scan.findings {
if let Some(code) = finding_failure_code(finding) {
let message: &str = if *finding == ScanFinding::PathnameLineBreak {
LINE_BREAK_MESSAGE
} else {
ENGINE_ERROR_MESSAGE
};
return Some(failed(code, message));
}
}
return None;
}
fn match_finding(finding: &ScanFinding, display_path: &str) -> Option<PolicyFinding> {
let message: String = match finding {
ScanFinding::Content { line, rule } => {
format!("Forbidden string matched at line {line} (rule {rule}).")
}
ScanFinding::Name { component, rule } => {
format!("Forbidden string matched in pathname segment {component} (rule {rule}).")
}
ScanFinding::EngineError | ScanFinding::PathnameLineBreak => return None,
};
return Some(PolicyFinding {
code: FORBIDDEN_STRING_CODE,
message,
path: Some(EventPath::display(display_path)),
location: None,
fix_available: false,
});
}
impl ScannerState {
fn load(
&mut self,
rules: &RulesSource,
builtin_rules: bool,
) -> Result<&CandidateScanner, PolicyOutcome> {
if self.loaded.is_none() {
self.loaded = Some(CandidateScanner::load(rules, builtin_rules));
if let Some(Ok(scanner)) = &self.loaded {
for skipped in scanner.skipped_optional_rules() {
debug(
"checkForbiddenStrings",
format!("optional rules file is absent; skipped: {}", skipped.display()).as_str(),
);
}
}
}
match &self.loaded {
Some(Ok(scanner)) => return Ok(scanner),
Some(Err(error)) => {
return Err(failed(
scanner_failure_code(error.failure),
error.message.as_str(),
));
}
None => {
return Err(failed(
EngineFailureCode::PolicyIncomplete,
"cli-git did not load the forbidden-strings rules; this is a defect in cli-git.",
));
}
}
}
}
fn repository_root<F: RepositoryFacts>(facts: &mut F) -> Result<PathBuf, PolicyOutcome> {
let location: RepositoryLocation = match facts.location() {
Ok(found) => found,
Err(message) => {
return Err(failed(
EngineFailureCode::ContentUnavailable,
message.as_str(),
));
}
};
match worktree_root(&location.identity) {
Some(root) => return Ok(root.to_path_buf()),
None => {
return Err(failed(
EngineFailureCode::ContentUnavailable,
"cli-git could not find the repository's top level to resolve the forbidden-strings rules file.",
));
}
}
}
fn known_content_or_none(
content: &mut ContentState,
lifecycle: &LifecycleContent,
rules_paths: &[Vec<u8>],
) -> KnownContent {
let LifecycleContent::Requested(request) = lifecycle else {
return KnownContent::default();
};
match content.known_content(request, rules_paths) {
Ok(found) => return found,
Err(error) => {
warn(
"checkForbiddenStrings",
format!(
"could not list the parent commits' files, so every candidate is scanned in full: {}",
error.message
)
.as_str(),
);
return KnownContent::default();
}
}
}
fn log_known_content(scans: &[CandidateScan], known: &KnownContent) {
for scan in scans {
if let Some(entry) = known.entry(scan.identity) {
debug(
"checkForbiddenStrings",
entry.describe(scan.display_path.as_str()).as_str(),
);
}
}
}
pub fn check_forbidden_strings<F: RepositoryFacts>(
content: &mut ContentState,
lifecycle: &LifecycleContent,
facts: &mut F,
settings: &ScannerSettings,
scanner: &mut ScannerState,
) -> PolicyOutcome {
let version: Rc<CandidateVersion> = match content.version(lifecycle, facts) {
Ok(Some(found)) => found,
Ok(None) => return PolicyOutcome::Findings(Vec::new()),
Err(outcome) => return outcome,
};
let root: PathBuf = match repository_root(facts) {
Ok(found) => found,
Err(outcome) => return outcome,
};
let selected: Result<RulesSource, RulesListError> = rules_source(
settings.options.rules_files.as_deref(),
settings.rules_variable.as_deref(),
root.as_path(),
);
let rules_paths: Vec<Vec<u8>> = match &selected {
Ok(rules) => rules_candidate_paths(rules, root.as_path()),
Err(_) => Vec::new(),
};
let mut any_scannable: bool = false;
for candidate in version.candidates() {
if is_scannable(candidate, rules_paths.as_slice()) {
any_scannable = true;
break;
}
}
if !any_scannable {
return PolicyOutcome::Findings(Vec::new());
}
let rules: RulesSource = match selected {
Ok(found) => found,
Err(error) => {
return failed(
scanner_failure_code(ScannerFailure::RulesNotLoaded),
format!(
"cli-git could not load the forbidden-strings rules, so no file was scanned: \
the value of {RULES_VARIABLE} is not a valid rules list: {error}."
)
.as_str(),
);
}
};
let loaded: &CandidateScanner = match scanner.load(&rules, settings.options.builtin_rules) {
Ok(found) => found,
Err(outcome) => return outcome,
};
let known: KnownContent = known_content_or_none(content, lifecycle, rules_paths.as_slice());
let scans: Vec<CandidateScan> =
match content.scan_skipping(loaded, rules_paths.as_slice(), &known) {
Ok(found) => found,
Err(error) => {
return failed(scan_run_failure_code(&error), error.to_string().as_str());
}
};
log_known_content(&scans, &known);
for scan in &scans {
if let Some(outcome) = incomplete_scan(scan) {
return outcome;
}
}
let mut findings: Vec<PolicyFinding> = Vec::new();
for scan in &scans {
for finding in &scan.findings {
if let Some(reported) = match_finding(finding, scan.display_path.as_str()) {
findings.push(reported);
}
}
}
return PolicyOutcome::Findings(findings);
}
#[cfg(test)]
#[path = "policy_forbidden_strings_tests.rs"]
mod tests;