rprobe 0.9.0

A simple tool to probe a remote host http or https connection
Documentation
// File: report.rs
// SPDX-License-Identifier: MIT OR Apache-2.0
//
// Copyright (c) 2023-2025
// - Volker Schwaberow <volker@schwaberow.de>

use chrono::Local;
use log::error;
use serde::Serialize;
use std::error::Error;
use std::fs::File;
use std::io::{Result, Write};
use std::path::PathBuf;

fn sanitize_filename(input: &str) -> String {
    let mut result: String = input
        .chars()
        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
        .collect::<String>()
        .chars()
        .take(255)
        .collect();

    while result.starts_with('.') {
        result = result.trim_start_matches('.').to_string();
    }

    if result.is_empty() {
        result = "file".to_string();
    }

    result
}

fn generate_safe_filename(output_path: &str) -> std::result::Result<String, Box<dyn Error>> {
    let path = PathBuf::from(output_path);

    if let Some(parent) = path.parent() {
        if !parent.exists() {
            std::fs::create_dir_all(parent)?;
        }

        let canonical_parent = parent.canonicalize()?;
        let canonical_path = path.canonicalize().unwrap_or_else(|_| {
            if let Some(filename) = path.file_name() {
                canonical_parent.join(sanitize_filename(&filename.to_string_lossy()))
            } else {
                canonical_parent.clone()
            }
        });

        if !canonical_path.starts_with(&canonical_parent) {
            return Err("Path traversal attempt detected".into());
        }
    }

    Ok(path.to_string_lossy().to_string())
}

#[derive(Debug, Serialize, Clone)]
pub struct ReportEntry {
    pub url: String,
    pub status: String,
    pub detections: Vec<String>,
}

#[derive(Debug)]
pub enum ReportFormat {
    Text,
    Json,
}

pub struct ReportGenerator;

impl ReportGenerator {
    pub fn generate_report(
        entries: &[ReportEntry],
        output_path: &str,
        format: ReportFormat,
    ) -> Result<()> {
        let safe_path = generate_safe_filename(output_path)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;

        match format {
            ReportFormat::Text => Self::generate_text_report(entries, &safe_path),
            ReportFormat::Json => Self::generate_json_report(entries, &safe_path),
        }
    }

    pub fn generate_text_report(entries: &[ReportEntry], output_path: &str) -> Result<()> {
        let mut file = File::create(output_path)?;

        writeln!(file, "rprobe Scan Report")?;
        writeln!(file, "Date: {}", Local::now().format("%Y-%m-%d %H:%M:%S"))?;
        writeln!(file, "Total URLs scanned: {}", entries.len())?;
        writeln!(file, "----------------------------------------")?;

        for entry in entries {
            let status_text = if entry.status == "0" {
                "Failed".to_string()
            } else {
                format!("HTTP {}", entry.status)
            };

            writeln!(file, "URL: {}", entry.url)?;
            writeln!(file, "Status: {}", status_text)?;

            if !entry.detections.is_empty() {
                writeln!(file, "Detections:")?;
                for detection in &entry.detections {
                    writeln!(file, "  - {}", detection)?;
                }
            } else {
                writeln!(file, "No detections")?;
            }

            writeln!(file, "----------------------------------------")?;
        }

        writeln!(file, "End of Report")?;
        Ok(())
    }

    pub fn generate_json_report(entries: &[ReportEntry], output_path: &str) -> Result<()> {
        #[derive(Serialize)]
        struct Report {
            date: String,
            total_urls: usize,
            entries: Vec<ReportEntry>,
        }

        let report = Report {
            date: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
            total_urls: entries.len(),
            entries: entries.to_vec(),
        };

        let json = serde_json::to_string_pretty(&report).unwrap_or_else(|e| {
            error!("Failed to serialize report to JSON: {}", e);
            String::from("Error generating JSON report")
        });

        let mut file = File::create(output_path)?;
        writeln!(file, "{}", json)?;

        Ok(())
    }

    pub fn generate_csv_report(entries: &[ReportEntry], output_path: &str) -> Result<()> {
        let safe_path = generate_safe_filename(output_path)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
        let mut file = File::create(safe_path)?;
        writeln!(file, "URL,Status,Detections")?;

        for entry in entries {
            let detections = entry.detections.join("; ");

            let escaped_detections = detections.replace('"', "\"\"");

            writeln!(
                file,
                "{},\"{}\",\"{}\"",
                entry.url, entry.status, escaped_detections
            )?;
        }

        Ok(())
    }

    pub fn generate_html_report(entries: &[ReportEntry], output_path: &str) -> Result<()> {
        let safe_path = generate_safe_filename(output_path)
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
        let mut file = File::create(safe_path)?;

        let mut detection_counts: std::collections::HashMap<String, usize> =
            std::collections::HashMap::new();
        let mut total_responsive = 0;
        let mut total_failed = 0;

        for entry in entries {
            if entry.status != "0" {
                total_responsive += 1;
            } else {
                total_failed += 1;
            }

            for detection in &entry.detections {
                *detection_counts.entry(detection.clone()).or_insert(0) += 1;
            }
        }

        let mut sorted_detections: Vec<(String, usize)> = detection_counts.into_iter().collect();
        sorted_detections.sort_by(|a, b| b.1.cmp(&a.1));

        writeln!(file, "<!DOCTYPE html>")?;
        writeln!(file, "<html lang=\"en\">")?;
        writeln!(file, "<head>")?;
        writeln!(file, "  <meta charset=\"UTF-8\">")?;
        writeln!(
            file,
            "  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">"
        )?;
        writeln!(file, "  <title>rprobe Scan Report</title>")?;
        writeln!(file, "  <style>")?;
        writeln!(
            file,
            "    body {{ font-family: Arial, sans-serif; margin: 0; padding: 20px; }}"
        )?;
        writeln!(
            file,
            "    .container {{ max-width: 1200px; margin: 0 auto; }}"
        )?;
        writeln!(file, "    h1, h2 {{ color: #333; }}")?;
        writeln!(file, "    .summary {{ background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin-bottom: 20px; }}")?;
        writeln!(file, "    .summary-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }}")?;
        writeln!(file, "    .summary-item {{ background-color: #fff; padding: 10px; border-radius: 5px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}")?;
        writeln!(
            file,
            "    .stat-number {{ font-size: 24px; font-weight: bold; }}"
        )?;
        writeln!(file, "    .stat-label {{ color: #666; }}")?;
        writeln!(
            file,
            "    table {{ width: 100%; border-collapse: collapse; }}"
        )?;
        writeln!(
            file,
            "    th {{ background-color: #4CAF50; color: white; text-align: left; padding: 8px; }}"
        )?;
        writeln!(file, "    td {{ border: 1px solid #ddd; padding: 8px; }}")?;
        writeln!(
            file,
            "    tr:nth-child(even) {{ background-color: #f2f2f2; }}"
        )?;
        writeln!(file, "    tr:hover {{ background-color: #ddd; }}")?;
        writeln!(file, "    .status-success {{ color: green; }}")?;
        writeln!(file, "    .status-redirect {{ color: orange; }}")?;
        writeln!(file, "    .status-error {{ color: red; }}")?;
        writeln!(file, "    .status-failed {{ color: darkred; }}")?;
        writeln!(file, "    .detection-tag {{ display: inline-block; margin: 2px; padding: 3px 6px; background-color: #e0e0e0; border-radius: 3px; font-size: 12px; }}")?;
        writeln!(file, "    .chart-container {{ margin-top: 20px; }}")?;
        writeln!(file, "  </style>")?;
        writeln!(file, "</head>")?;
        writeln!(file, "<body>")?;
        writeln!(file, "  <div class=\"container\">")?;
        writeln!(file, "    <h1>rprobe Scan Report</h1>")?;
        writeln!(
            file,
            "    <p>Report generated on: {}</p>",
            Local::now().format("%Y-%m-%d %H:%M:%S")
        )?;
        writeln!(file, "    <div class=\"summary\">")?;
        writeln!(file, "      <h2>Summary</h2>")?;
        writeln!(file, "      <div class=\"summary-grid\">")?;
        writeln!(file, "        <div class=\"summary-item\">")?;
        writeln!(
            file,
            "          <div class=\"stat-number\">{}</div>",
            entries.len()
        )?;
        writeln!(
            file,
            "          <div class=\"stat-label\">Total URLs Scanned</div>"
        )?;
        writeln!(file, "        </div>")?;
        writeln!(file, "        <div class=\"summary-item\">")?;
        writeln!(
            file,
            "          <div class=\"stat-number\">{}</div>",
            total_responsive
        )?;
        writeln!(file, "          <div class=\"stat-label\">Responsive</div>")?;
        writeln!(file, "        </div>")?;
        writeln!(file, "        <div class=\"summary-item\">")?;
        writeln!(
            file,
            "          <div class=\"stat-number\">{}</div>",
            total_failed
        )?;
        writeln!(file, "          <div class=\"stat-label\">Failed</div>")?;
        writeln!(file, "        </div>")?;
        writeln!(file, "        <div class=\"summary-item\">")?;
        writeln!(
            file,
            "          <div class=\"stat-number\">{}</div>",
            sorted_detections.len()
        )?;
        writeln!(
            file,
            "          <div class=\"stat-label\">Unique Detections</div>"
        )?;
        writeln!(file, "        </div>")?;
        writeln!(file, "      </div>")?;
        writeln!(file, "    </div>")?;

        if !sorted_detections.is_empty() {
            writeln!(file, "    <h2>Top Detections</h2>")?;
            writeln!(file, "    <table>")?;
            writeln!(file, "      <tr><th>Detection</th><th>Count</th></tr>")?;

            for (detection, count) in sorted_detections.iter().take(10) {
                writeln!(
                    file,
                    "      <tr><td>{}</td><td>{}</td></tr>",
                    detection, count
                )?;
            }

            writeln!(file, "    </table>")?;
        }

        writeln!(file, "    <h2>Scan Results</h2>")?;
        writeln!(file, "    <table>")?;
        writeln!(
            file,
            "      <tr><th>URL</th><th>Status</th><th>Detections</th></tr>"
        )?;

        for entry in entries {
            let status_class = if entry.status == "0" {
                "status-failed"
            } else {
                let status_code = entry.status.parse::<u16>().unwrap_or(0);
                if (200..300).contains(&status_code) {
                    "status-success"
                } else if (300..400).contains(&status_code) {
                    "status-redirect"
                } else {
                    "status-error"
                }
            };

            let status_text = if entry.status == "0" {
                "Failed".to_string()
            } else {
                format!("HTTP {}", entry.status)
            };

            writeln!(file, "      <tr>")?;
            writeln!(file, "        <td>{}</td>", entry.url)?;
            writeln!(
                file,
                "        <td class=\"{}\">'{}'</td>",
                status_class, status_text
            )?;

            writeln!(file, "        <td>")?;
            for detection in &entry.detections {
                writeln!(
                    file,
                    "          <span class=\"detection-tag\">{}</span>",
                    detection
                )?;
            }
            writeln!(file, "        </td>")?;

            writeln!(file, "      </tr>")?;
        }

        writeln!(file, "    </table>")?;
        writeln!(file, "  </div>")?;
        writeln!(file, "</body>")?;
        writeln!(file, "</html>")?;

        Ok(())
    }
}

#[cfg(test)]
#[path = "report_tests.rs"]
mod tests;