use crate::engine::{CompiledPolicy, Hit};
use crate::extract::{Doc, RegionKind};
use crate::input::Prepared;
use crate::policy::{Lifecycle, Tier};
use crate::{AnalysisError, Config, Stance, WaiverAuthority};
use serde::Serialize;
use serde_json::value::RawValue;
use std::ops::Range;
pub const SNIPPET_CAP: usize = 200;
#[derive(Serialize, Clone, Debug)]
pub struct SpanOut {
pub start: usize,
pub end: usize,
}
#[derive(Serialize, Clone, Debug)]
pub struct Suggestion {
pub start: usize,
pub end: usize,
pub replace_with: String,
}
#[derive(Serialize, Debug)]
pub struct Finding {
pub rule_id: String,
pub family: String,
pub state: String,
pub lifecycle: String,
pub waived: bool,
pub spans: Vec<SpanOut>,
pub view: String,
pub snippet: Box<RawValue>,
#[serde(skip_serializing_if = "Option::is_none")]
pub section: Option<String>,
pub provenance: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub suggestion: Option<Suggestion>,
pub message: String,
}
#[derive(Serialize, Debug)]
pub struct PolicyRef {
pub version: String,
pub digest: String,
}
#[derive(Serialize, Debug)]
pub struct ArtifactRef {
pub sha256: String,
pub bytes: usize,
pub encoding: String,
pub input_format: String,
pub profile: String,
}
#[derive(Serialize, Debug)]
pub struct ExcludedOut {
pub start: usize,
pub end: usize,
pub reason: String,
}
#[derive(Serialize, Debug)]
pub struct Segmentation {
pub prose_bytes: usize,
pub excluded: Vec<ExcludedOut>,
}
#[derive(Serialize, Debug)]
pub struct SectionOut {
pub title: String,
pub level: u32,
pub start: usize,
pub end: usize,
}
#[derive(Serialize, Debug)]
pub struct Coverage {
pub sections: Vec<SectionOut>,
pub segmentation: Segmentation,
pub notes: Vec<String>,
}
#[derive(Serialize, Debug)]
pub struct Report {
pub schema_version: &'static str,
pub tool_version: &'static str,
pub policy: PolicyRef,
pub artifact: ArtifactRef,
pub result_state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
pub findings: Vec<Finding>,
pub coverage: Coverage,
}
impl Report {
pub fn exit_code(&self) -> i32 {
match self.result_state.as_str() {
"instrumentation_error" => 30,
"unsupported_input" => 40,
"violations_present" => 10,
"candidates_present" => 20,
_ => 0,
}
}
}
pub fn escape_json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
c if (c as u32) < 0x20 || ((c as u32) >= 0x7F && (c as u32) <= 0x9F) => {
out.push_str(&format!("\\u{:04X}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
fn slice_carries_trigger(raw_slice: &str, norm_text: &str, decoded: &str, trigger: &str) -> bool {
if !decoded.is_empty() && decoded.contains(trigger) {
return true;
}
let want = crate::views::render_key(trigger);
if want.is_empty() {
return true;
}
crate::views::render_key(raw_slice).contains(&want)
|| crate::views::render_key(norm_text).contains(&want)
}
fn snippet_raw(src: &str, span: &Range<usize>) -> Result<Box<RawValue>, AnalysisError> {
let full = src.get(span.clone()).ok_or_else(|| {
AnalysisError::Instrumentation(format!(
"span {}..{} does not index the payload on a char boundary",
span.start, span.end
))
})?;
if full.as_bytes() != &src.as_bytes()[span.start..span.end] {
return Err(AnalysisError::Instrumentation(format!(
"span invariant failed at {}..{}",
span.start, span.end
)));
}
let mut cap = full.len().min(SNIPPET_CAP);
while cap > 0 && !full.is_char_boundary(cap) {
cap -= 1;
}
let escaped = escape_json_string(&full[..cap]);
RawValue::from_string(escaped)
.map_err(|e| AnalysisError::Instrumentation(format!("snippet escape: {e}")))
}
fn section_for(doc: &Doc, offset: usize) -> Option<String> {
doc.sections
.iter()
.filter(|s| s.range.start <= offset && offset < s.range.end)
.max_by_key(|s| (s.level, s.range.start))
.map(|s| s.title.clone())
}
fn suggestion_for(rule_id: &str, span: &Range<usize>) -> Option<Suggestion> {
let replace_with = match rule_id {
"SLOP-M001" => ", ",
"SLOP-M002" => ".",
"SLOP-M003" | "SLOP-M006" | "SLOP-T001" | "SLOP-I001" => "",
_ => return None,
};
Some(Suggestion {
start: span.start,
end: span.end,
replace_with: replace_with.to_string(),
})
}
struct WaiverDecision {
waived: bool,
notes: Vec<String>,
}
fn waiver_decision(
cp: &CompiledPolicy,
config: &Config,
rule_id: &str,
human_only: bool,
span: &Range<usize>,
) -> WaiverDecision {
let mut notes = Vec::new();
for w in &config.waivers {
if w.rule_id != rule_id {
continue;
}
if cp.pkg.rule_by_id(&w.rule_id).is_none() {
notes.push(format!("waiver for unknown rule {} ignored", w.rule_id));
continue;
}
if let Some(ws) = &w.span {
if !(ws.start <= span.start && ws.end >= span.end) {
continue;
}
}
if let (Some(expires), Some(now)) = (&w.expires, config.now_unix) {
match crate::waiver::parse_rfc3339(expires) {
Some(t) if t < now => {
notes.push(format!("expired waiver for {} ignored", w.rule_id));
continue;
}
None => {
notes.push(format!(
"waiver for {} has an unreadable expiry and is ignored",
w.rule_id
));
continue;
}
_ => {}
}
}
let authority = config
.deployment
.waiver_authority
.unwrap_or(WaiverAuthority::Human);
if let Err(reason) =
crate::waiver::floor_allows(w.signer_kind.as_deref(), rule_id, human_only, authority)
{
notes.push(reason);
continue;
}
return WaiverDecision {
waived: true,
notes,
};
}
WaiverDecision {
waived: false,
notes,
}
}
pub fn assemble(
cp: &CompiledPolicy,
prepared: &Prepared,
doc: &Doc,
norm: &crate::views::NormView,
config: &Config,
hits: Vec<Hit>,
) -> Result<Report, AnalysisError> {
let src = prepared.text.as_str();
let mut notes: Vec<String> = Vec::new();
if prepared.bom_stripped {
notes.push("leading BOM stripped; offsets index the post-BOM payload".to_string());
}
if prepared.mixed_line_endings {
notes.push("mixed line endings".to_string());
}
let mut findings: Vec<Finding> = Vec::new();
let mut adversarial = false;
for hit in &hits {
let rule = &cp.pkg.rules[hit.rule];
if rule.lifecycle == Lifecycle::Deprecated {
continue;
}
let stance = rule.stance(config.profile, hit.field);
if stance == Stance::Off {
continue;
}
if hit.quoted && cp.pkg.quotation_suppress.iter().any(|id| id == &rule.id) {
continue;
}
if findings.len() >= config.limits.max_findings {
return Err(AnalysisError::Instrumentation(format!(
"findings exceed the {} limit",
config.limits.max_findings
)));
}
let mut tier = rule.tier;
let mut lifecycle = rule.lifecycle;
let mut provenance = "author".to_string();
if hit.force_hint {
tier = Tier::CoverageHint;
lifecycle = Lifecycle::Advisory;
} else if hit.force_candidate && tier == Tier::Violation {
tier = Tier::Candidate;
}
if stance == Stance::Relax {
match tier {
Tier::Violation => tier = Tier::Candidate,
Tier::Candidate => lifecycle = Lifecycle::Advisory,
Tier::CoverageHint => {}
}
}
if hit.quoted
&& tier == Tier::Violation
&& cp.pkg.quotation_downgrade.iter().any(|id| id == &rule.id)
{
tier = Tier::Candidate;
provenance = "claimed-quotation".to_string();
}
if config.deployment.demote.iter().any(|id| id == &rule.id) {
lifecycle = Lifecycle::Advisory;
}
if rule.id == "SLOP-J001" {
adversarial = true;
}
let decision = waiver_decision(cp, config, &rule.id, rule.human_only_waiver, &hit.span);
notes.extend(decision.notes);
let snippet = snippet_raw(src, &hit.span)?;
if let Some(trigger) = &hit.trigger {
if !slice_carries_trigger(
&src[hit.span.clone()],
&norm.source_span_norm_text(&hit.span),
hit.decoded.as_deref().unwrap_or(""),
trigger,
) {
return Err(AnalysisError::Instrumentation(format!(
"trigger-fidelity: source slice {}..{} does not render to the matched trigger",
hit.span.start, hit.span.end
)));
}
}
let suggestion = if config.suggest && !hit.force_hint {
suggestion_for(&rule.id, &hit.span)
} else {
None
};
findings.push(Finding {
rule_id: rule.id.clone(),
family: rule.family.clone(),
state: tier.as_str().to_string(),
lifecycle: lifecycle.as_str().to_string(),
waived: decision.waived,
spans: vec![SpanOut {
start: hit.span.start,
end: hit.span.end,
}],
view: rule.view.as_str().to_string(),
snippet,
section: section_for(doc, hit.span.start),
provenance,
suggestion,
message: match &hit.detail {
Some(d) => format!("{}: {d}", rule.name.replace('-', " ")),
None => rule.name.replace('-', " "),
},
});
}
findings.sort_by(|a, b| {
(a.spans[0].start, a.spans[0].end, a.rule_id.as_str()).cmp(&(
b.spans[0].start,
b.spans[0].end,
b.rule_id.as_str(),
))
});
findings.dedup_by(|a, b| {
a.rule_id == b.rule_id
&& a.spans[0].start == b.spans[0].start
&& a.spans[0].end == b.spans[0].end
&& a.state == b.state
});
if adversarial {
notes.push(
"injection pattern present: adversarial mode, all candidates require human adjudication"
.to_string(),
);
}
let mut has_violation = false;
let mut has_candidate = false;
for f in &findings {
if f.waived || f.lifecycle != "blocking" {
continue;
}
match f.state.as_str() {
"violation" => has_violation = true,
"candidate" => has_candidate = true,
_ => {}
}
}
let result_state = if has_violation {
"violations_present"
} else if has_candidate {
"candidates_present"
} else {
"no_findings"
};
let prose_bytes: usize = doc
.regions
.iter()
.filter(|r| r.kind == RegionKind::Prose)
.map(|r| r.range.len())
.sum();
let excluded = doc
.regions
.iter()
.filter(|r| r.kind != RegionKind::Prose)
.map(|r| ExcludedOut {
start: r.range.start,
end: r.range.end,
reason: r.kind.reason().to_string(),
})
.collect();
let sections = doc
.sections
.iter()
.map(|s| SectionOut {
title: s.title.clone(),
level: s.level,
start: s.range.start,
end: s.range.end,
})
.collect();
let note = if config.suggest {
Some(
"applying any suggestion changes the artifact hash and invalidates any approval"
.to_string(),
)
} else {
None
};
Ok(Report {
schema_version: crate::SCHEMA_VERSION,
tool_version: crate::TOOL_VERSION,
policy: PolicyRef {
version: cp.pkg.version.clone(),
digest: cp.pkg.digest.clone(),
},
artifact: ArtifactRef {
sha256: prepared.sha256.clone(),
bytes: prepared.original_len,
encoding: "utf-8".to_string(),
input_format: config.input_format.as_str().to_string(),
profile: config.profile.as_str().to_string(),
},
result_state: result_state.to_string(),
note,
findings,
coverage: Coverage {
sections,
segmentation: Segmentation {
prose_bytes,
excluded,
},
notes,
},
})
}
#[cfg(test)]
mod trigger_fidelity_tests {
use super::slice_carries_trigger;
#[test]
fn raw_slice_legitimate_divergences_verify() {
assert!(slice_carries_trigger("not\njust", "", "", "not just"));
assert!(slice_carries_trigger("a—b", "", "", "a\u{2014}b"));
assert!(slice_carries_trigger(r"foo\*bar", "", "", "foo*bar"));
assert!(slice_carries_trigger("de<b></b>lve", "", "", "delve"));
assert!(slice_carries_trigger(
"game <i>changer</i>",
"",
"",
"game changer"
));
assert!(slice_carries_trigger("DELVE", "", "", "delve"));
assert!(slice_carries_trigger(
"A comprehensive tapestry to delve into",
"",
"",
"delve"
));
}
#[test]
fn norm_text_reconstruction_respects_inline_code_barrier() {
assert!(!slice_carries_trigger(
"de`x`lve",
"de\u{FFFD}lve",
"",
"delve"
));
assert!(!slice_carries_trigger("de`x`lve", "parser", "", "delve"));
assert!(slice_carries_trigger("x", "a\u{2014}b", "", "a\u{2014}b"));
}
#[test]
fn corrupted_span_fails_closed() {
assert!(!slice_carries_trigger("The parser reads", "", "", "delve"));
assert!(!slice_carries_trigger("elve", "", "", "delve"));
assert!(!slice_carries_trigger("", "", "", "delve"));
}
#[test]
fn decoded_destination_reconstruction_verifies_outside_entity_table() {
assert!(slice_carries_trigger(
"https://e/?utm_source=chatgpt",
"",
"https://e/?utm_source=chatgpt",
"utm_source=chatgpt"
));
assert!(!slice_carries_trigger(
"https://e/?utm_source=chatgpt",
"",
"https://e/?clean",
"utm_source=chatgpt"
));
}
}