//! Annotated HTML builder for study mode. //! //! Takes the original document bytes and the scan report, and produces //! a self-contained HTML file with inline annotations around findings. //! //! For formats where we have structured content (PDF, EPUB, DOCX), //! we extract text with location info and wrap findings in ``. //! For Markdown, we parse the source directly and inject spans. use crate::core::types::{DocumentFormat, ScanReport, ThreatClassification, Finding}; /// Build the complete study-mode HTML document. pub fn build_study_html( raw_bytes: &[u8], scan_report: &ScanReport, sha256: &str, source_path: &std::path::Path, ) -> String { let format = scan_report.format; // Build a mapping from location string to findings for O(1) lookup. let mut location_findings: std::collections::HashMap> = std::collections::HashMap::new(); for finding in &scan_report.findings { location_findings .entry(finding.location.to_string()) .or_default() .push(finding); } let body_content = match format { DocumentFormat::Markdown => build_markdown_study(raw_bytes, &location_findings), _ => build_generic_study(raw_bytes, format, &location_findings), }; let filename = source_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown"); format!( r#" CorbelPurge Study: {filename}

CorbelPurge Study Mode

File
{filename}
SHA-256
{sha256}
Format
{format}
Text nodes
{text_nodes}
Vectors
{vectors}
Findings
{total}
Malicious: {malicious} Suspicious: {suspicious} Educational: {educational}
{body_content} "#, filename = html_escape(filename), sha256 = sha256, format = format, text_nodes = scan_report.text_nodes_scanned, vectors = scan_report.vectors_scanned, total = scan_report.findings.len(), malicious = scan_report.malicious_count(), suspicious = scan_report.findings.iter().filter(|f| matches!(f.classification, ThreatClassification::Suspicious)).count(), educational = scan_report.educational_count(), body_content = body_content, ) } /// Build study HTML for Markdown source files. /// /// Parses the Markdown line-by-line and wraps matching content in spans. fn build_markdown_study( raw: &[u8], location_findings: &std::collections::HashMap>, ) -> String { let text = String::from_utf8_lossy(raw); let mut out = String::new(); out.push_str("
\n");

    for (idx, line) in text.lines().enumerate() {
        let line_num = idx + 1;
        let location_key = format!("md:{}:0", line_num);

        let mut line_html = html_escape(line);

        if let Some(findings) = location_findings.get(&location_key) {
            for finding in findings {
                let class = classification_class(&finding.classification);
                line_html = format!(
                    "{} {}",
                    class, line_html, classification_label(&finding.classification)
                );
            }
        }

        out.push_str(&format!(
            "{line_num:>4}{}\n",
            line_html
        ));
    }

    out.push_str("
\n"); out } /// Build study HTML for non-Markdown formats (PDF, EPUB, DOCX). /// /// Since we can't reliably reconstruct the original rendering, we show /// the findings in a structured list with their payload previews. /// /// The `raw` bytes and `format` are accepted for symmetry with /// [`build_markdown_study`] and for future per-format rendering hooks, /// but are not currently consumed by this implementation. #[allow(unused_variables)] fn build_generic_study( raw: &[u8], format: DocumentFormat, location_findings: &std::collections::HashMap>, ) -> String { let mut out = String::new(); // Group findings by location for ordered display. let mut seen = std::collections::HashSet::new(); for finding in location_findings .values() .flatten() .collect::>() { if !seen.insert(finding.location.to_string()) { continue; } let class = classification_class(&finding.classification); out.push_str(&format!( "
\n", class )); out.push_str(&format!( "
{}
\n", class, classification_label(&finding.classification) )); out.push_str(&format!( "
{}
\n", finding.location )); out.push_str(&format!( "
{}
\n", html_escape(&finding.context_notes) )); if !finding.payload_preview.is_empty() { out.push_str(&format!( "
{}
\n", html_escape(&finding.payload_preview) )); } out.push_str("
\n"); } // If no findings, show raw content in a code block. if location_findings.is_empty() { out.push_str("

No findings. Document passed all checks.

\n"); } out } fn classification_class(c: &ThreatClassification) -> &'static str { match c { ThreatClassification::Benign => "benign", ThreatClassification::Suspicious => "suspicious", ThreatClassification::EducationalContent => "educational", ThreatClassification::Malicious(_) => "malicious", } } fn classification_label(c: &ThreatClassification) -> &'static str { match c { ThreatClassification::Benign => "benign", ThreatClassification::Suspicious => "suspicious", ThreatClassification::EducationalContent => "educational", ThreatClassification::Malicious(_) => "malicious", } } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) } #[cfg(test)] mod tests { use super::*; #[test] fn html_escape_basic() { assert_eq!(html_escape(""), "<script>alert('xss')</script>"); } #[test] fn build_study_html_has_structure() { use crate::core::types::*; let report = ScanReport { source_sha256: "abc".to_string(), format: DocumentFormat::Markdown, scanned_at: "x".to_string(), findings: vec![Finding { classification: ThreatClassification::Malicious(MaliciousType::ActiveJavaScriptInjection), location: Location::MarkdownLine { line: 3, col: 0 }, vector_type: None, payload_preview: "eval(1)".to_string(), context_notes: "found evil".to_string(), recommendation: Recommendation::QuarantineAndCleanse, }], text_nodes_scanned: 5, vectors_scanned: 1, }; let html = build_study_html(b"hello\nworld\nevil", &report, "abc123", std::path::Path::new("test.md")); assert!(html.contains("")); assert!(html.contains(" 3")); assert!(html.contains("CorbelPurge Study Mode")); assert!(html.contains("Malicious:")); } }