use std::collections::BTreeMap;
use crate::ScanReport;
use super::{TransformError, ensure_non_overlapping, validated_spans};
const DEFAULT_NAMESPACE: &str = "CRIBRA";
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct TemplateOptions {
namespace: String,
numbered: bool,
}
impl Default for TemplateOptions {
fn default() -> Self {
Self {
namespace: DEFAULT_NAMESPACE.to_owned(),
numbered: false,
}
}
}
impl TemplateOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
self.namespace = namespace.into();
self
}
#[must_use]
pub const fn numbered(mut self, numbered: bool) -> Self {
self.numbered = numbered;
self
}
#[must_use]
pub const fn is_numbered(&self) -> bool {
self.numbered
}
}
pub fn template(source: &str, report: &ScanReport) -> Result<String, TransformError> {
template_with(source, report, &TemplateOptions::default())
}
pub fn template_with(
source: &str,
report: &ScanReport,
options: &TemplateOptions,
) -> Result<String, TransformError> {
let spans = validated_spans(source, report)?;
ensure_non_overlapping(&spans)?;
if spans.is_empty() {
return Ok(source.to_owned());
}
let namespace = normalize_component(&options.namespace, DEFAULT_NAMESPACE);
let mut occurrences = BTreeMap::<&str, usize>::new();
let mut output = String::with_capacity(source.len());
let mut cursor = 0;
for span in spans {
output.push_str(&source[cursor..span.start]);
let rule_id = span.finding.rule_id().as_str();
let rule = normalize_component(rule_id, "finding");
output.push('<');
output.push_str(&namespace);
output.push(':');
output.push_str(&rule);
if options.numbered {
let occurrence = occurrences.entry(rule_id).or_default();
*occurrence += 1;
output.push(':');
output.push_str(&occurrence.to_string());
}
output.push('>');
cursor = span.end;
}
output.push_str(&source[cursor..]);
Ok(output)
}
fn normalize_component(value: &str, fallback: &str) -> String {
let mut output = String::with_capacity(value.len());
let mut separator_pending = false;
for character in value.chars() {
if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') {
if separator_pending && !output.is_empty() {
output.push('-');
}
separator_pending = false;
output.push(character);
} else {
separator_pending = true;
}
}
while output.ends_with('-') {
output.pop();
}
if output.is_empty() {
fallback.to_owned()
} else {
output
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Confidence, Finding, Location, RuleId, Severity};
fn finding(id: &str, start: usize, end: usize) -> Finding {
Finding::new(
RuleId::from(id),
Location::from_span(start, end),
Severity::High,
Confidence::High,
None,
)
}
#[test]
fn replaces_value_with_rule_placeholder() {
let report =
ScanReport::new_with_candidates(vec![finding("stripe.secret", 6, 12)], Vec::new());
assert_eq!(
template("TOKEN=SECRET", &report).unwrap(),
"TOKEN=<CRIBRA:stripe.secret>",
);
}
#[test]
fn preserves_text_outside_findings() {
let source = "A=SECRET B=PASSWORD C=public";
let report = ScanReport::new_with_candidates(
vec![finding("api-key", 2, 8), finding("password", 11, 19)],
Vec::new(),
);
assert_eq!(
template(source, &report).unwrap(),
"A=<CRIBRA:api-key> B=<CRIBRA:password> C=public",
);
}
#[test]
fn supports_custom_namespace() {
let report = ScanReport::new_with_candidates(vec![finding("secret", 6, 12)], Vec::new());
let options = TemplateOptions::new().namespace("EXAMPLE");
assert_eq!(
template_with("TOKEN=SECRET", &report, &options).unwrap(),
"TOKEN=<EXAMPLE:secret>",
);
}
#[test]
fn normalizes_unsafe_placeholder_components() {
let report = ScanReport::new_with_candidates(
vec![finding("custom rule > token", 6, 12)],
Vec::new(),
);
let options = TemplateOptions::new().namespace("My App!");
assert_eq!(
template_with("TOKEN=SECRET", &report, &options).unwrap(),
"TOKEN=<My-App:custom-rule-token>",
);
}
#[test]
fn empty_namespace_falls_back_to_cribra() {
let report = ScanReport::new_with_candidates(vec![finding("secret", 6, 12)], Vec::new());
let options = TemplateOptions::new().namespace("!!!");
assert_eq!(
template_with("TOKEN=SECRET", &report, &options).unwrap(),
"TOKEN=<CRIBRA:secret>",
);
}
#[test]
fn numbering_is_deterministic_per_rule() {
let source = "SECRET x SECRET y OTHER";
let report = ScanReport::new_with_candidates(
vec![
finding("secret", 0, 6),
finding("secret", 9, 15),
finding("other", 18, 23),
],
Vec::new(),
);
let options = TemplateOptions::new().numbered(true);
assert_eq!(
template_with(source, &report, &options).unwrap(),
"<CRIBRA:secret:1> x <CRIBRA:secret:2> y <CRIBRA:other:1>",
);
}
#[test]
fn empty_report_preserves_source_exactly() {
let source = "ordinary 😀 UTF-8";
assert_eq!(template(source, &ScanReport::default()).unwrap(), source,);
}
#[test]
fn overlapping_findings_are_rejected_as_ambiguous() {
let report = ScanReport::new_with_candidates(
vec![finding("short", 0, 6), finding("long", 0, 10)],
Vec::new(),
);
assert!(matches!(
template("0123456789", &report),
Err(TransformError::OverlappingSpans { .. }),
));
}
#[test]
fn adjacent_findings_remain_independent() {
let report = ScanReport::new_with_candidates(
vec![finding("left", 0, 3), finding("right", 3, 6)],
Vec::new(),
);
assert_eq!(
template("ABCDEF", &report).unwrap(),
"<CRIBRA:left><CRIBRA:right>",
);
}
#[test]
fn matched_secret_never_appears_in_placeholder() {
let source = "TOKEN=SUPER_SECRET_VALUE";
let start = "TOKEN=".len();
let report = ScanReport::new_with_candidates(
vec![finding("credential", start, source.len())],
Vec::new(),
);
let output = template(source, &report).unwrap();
assert_eq!(output, "TOKEN=<CRIBRA:credential>");
assert!(!output.contains("SUPER_SECRET_VALUE"));
}
}