#![cfg(unix)]
use super::{CandidateScanner, RulesSource, ScannerError, ScannerFailure};
use crate::candidate_object::{CandidateMode, parse_object_id};
use crate::candidate_record::CandidateChange;
use crate::candidate_version::{Candidate, CandidateIdentity};
use crate::scanner_test_support::{needle, rules_file, run_isolated};
use forbidden_strings::{CandidateScan, ScanFinding};
use std::path::{Path, PathBuf};
fn candidate(index: usize, path: &[u8]) -> Candidate {
return Candidate {
identity: CandidateIdentity {
generation: 0,
index,
},
path: path.to_vec(),
mode: CandidateMode::Regular,
change: CandidateChange::Added,
object: parse_object_id(&[b'a'; 40]),
};
}
fn loaded(directory: &Path) -> CandidateScanner {
let rules: RulesSource = RulesSource {
path: rules_file(directory),
explicit: true,
};
match CandidateScanner::load(&rules, false) {
Ok(scanner) => return scanner,
Err(error) => panic!("planted rules must load: {error}"),
}
}
fn load_failure(rules: &RulesSource, builtin_rules: bool) -> ScannerError {
match CandidateScanner::load(rules, builtin_rules) {
Ok(_) => panic!("the load must be refused"),
Err(error) => return error,
}
}
fn rules_are_loaded_once_body(directory: &Path) {
let scanner: CandidateScanner = loaded(directory);
std::fs::remove_file(directory.join("rules.txt")).expect("remove rules after loading");
let cache: PathBuf = PathBuf::from(
std::env::var_os("FORBIDDEN_STRINGS_CACHE_DIR").expect("isolated cache directory"),
);
std::fs::remove_dir_all(&cache).expect("remove cache after loading");
let token: String = needle();
let leak: Vec<u8> = format!("first line\n{token}\nthird line\n").into_bytes();
let first: CandidateScan = scanner
.scan(&candidate(4, b"src/a.txt"), leak.as_slice())
.expect("scan");
let second: CandidateScan = scanner
.scan(&candidate(9, b"src/b.txt"), leak.as_slice())
.expect("scan");
let clean: CandidateScan = scanner
.scan(&candidate(2, b"src/c.txt"), b"nothing here\n")
.expect("scan");
assert_eq!(first.identity, 4);
assert_eq!(second.identity, 9);
assert_eq!(clean.identity, 2);
for scan in [&first, &second] {
assert_eq!(
scan.findings,
vec![ScanFinding::Content {
line: 2,
rule: String::from("0")
}]
);
assert_eq!(scan.scanned_bytes, leak.len());
}
assert_eq!(first.display_path, "src/a.txt");
assert!(clean.findings.is_empty());
assert!(!format!("{first:?}{second:?}{clean:?}").contains(token.as_str()));
}
#[test]
fn rules_are_loaded_once() {
run_isolated(
"scanner_adapter::tests::rules_are_loaded_once",
"scanner-once",
rules_are_loaded_once_body,
);
}
fn pathnames_are_scanned_and_masked_body(directory: &Path) {
let scanner: CandidateScanner = loaded(directory);
let token: String = needle();
let named: Vec<u8> = format!("private/{token}/notes.txt").into_bytes();
let by_name: CandidateScan = scanner
.scan(&candidate(0, named.as_slice()), b"clean\n")
.expect("scan");
assert_eq!(
by_name.findings,
vec![ScanFinding::Name {
component: 2,
rule: String::from("0")
}]
);
assert_eq!(by_name.display_path, "private/[REDACTED]/notes.txt");
let mut bytes_name: Vec<u8> = b"dir\xff/".to_vec();
bytes_name.extend_from_slice(token.as_bytes());
let by_bytes: CandidateScan = scanner
.scan(&candidate(1, bytes_name.as_slice()), b"")
.expect("scan");
assert_eq!(
by_bytes.findings,
vec![ScanFinding::Name {
component: 2,
rule: String::from("0")
}]
);
assert_eq!(by_bytes.display_path, "dir\\xff/[REDACTED]");
let broken: CandidateScan = scanner
.scan(&candidate(2, b"two\nlines.txt"), b"clean\n")
.expect("scan");
assert_eq!(broken.findings, vec![ScanFinding::PathnameLineBreak]);
assert!(!format!("{by_name:?}{by_bytes:?}{broken:?}").contains(token.as_str()));
}
#[test]
fn pathnames_are_scanned_and_masked() {
run_isolated(
"scanner_adapter::tests::pathnames_are_scanned_and_masked",
"scanner-names",
pathnames_are_scanned_and_masked_body,
);
}
fn cache_warnings_are_the_scanners_own_body(directory: &Path) {
let first: CandidateScanner = loaded(directory);
assert_eq!(first.cache_warnings().len(), 1);
assert_eq!(first.cache_warnings()[0].reason(), "missing");
assert_eq!(first.cache_warnings()[0].recovery(), "compile-from-text");
let second: CandidateScanner = loaded(directory);
assert!(second.cache_warnings().is_empty());
}
#[test]
fn cache_warnings_are_the_scanners_own() {
run_isolated(
"scanner_adapter::tests::cache_warnings_are_the_scanners_own",
"scanner-cache",
cache_warnings_are_the_scanners_own_body,
);
}
fn load_failures_leave_no_scanner_body(directory: &Path) {
let missing: PathBuf = directory.join("missing.txt");
let explicit: RulesSource = RulesSource {
path: missing.clone(),
explicit: true,
};
let implicit: RulesSource = RulesSource {
path: missing,
explicit: false,
};
for (rules, builtin_rules) in [(&explicit, false), (&explicit, true), (&implicit, false)] {
let error: ScannerError = load_failure(rules, builtin_rules);
assert_eq!(error.failure, ScannerFailure::RulesNotLoaded);
assert!(
error.message.starts_with(
"cli-git could not load the forbidden-strings rules, so no file was scanned: read rules "
),
"{error}"
);
assert_eq!(error.to_string(), error.message);
}
let baseline: CandidateScanner = match CandidateScanner::load(&implicit, true) {
Ok(scanner) => scanner,
Err(error) => panic!("the baseline alone must load: {error}"),
};
assert!(baseline.cache_warnings().is_empty());
assert!(
baseline
.scan(&candidate(0, b"plain.txt"), b"plain text\n")
.expect("scan")
.findings
.is_empty()
);
let pattern: String = ["PRIVATE", "PATTERN", "BODY"].join("_");
let invalid: PathBuf = directory.join("invalid.txt");
std::fs::write(&invalid, format!("/{pattern}/g\n")).expect("invalid rules file");
let refused: ScannerError = load_failure(
&RulesSource {
path: invalid,
explicit: true,
},
false,
);
assert_eq!(refused.failure, ScannerFailure::RulesNotLoaded);
assert!(!refused.message.contains(pattern.as_str()), "{refused}");
}
#[test]
fn load_failures_leave_no_scanner() {
run_isolated(
"scanner_adapter::tests::load_failures_leave_no_scanner",
"scanner-load",
load_failures_leave_no_scanner_body,
);
}
fn unrepresentable_pathname_is_refused_body(directory: &Path) {
let scanner: CandidateScanner = loaded(directory);
let error: ScannerError = match scanner.scan(&candidate(7, b""), b"content\n") {
Ok(_) => panic!("an empty pathname must be refused"),
Err(failure) => failure,
};
assert_eq!(error.failure, ScannerFailure::PathnameUnrepresentable);
assert_eq!(
error.message,
"cli-git could not scan candidate 7: its pathname cannot be expressed as a native path on this platform."
);
}
#[test]
fn unrepresentable_pathname_is_refused() {
run_isolated(
"scanner_adapter::tests::unrepresentable_pathname_is_refused",
"scanner-pathname",
unrepresentable_pathname_is_refused_body,
);
}