use std::{
fs::File,
io::Write,
path::Path,
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use anyhow::{Context, Result};
use serde_json::json;
use crate::scan::types::SecretMatch;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ReportFormat {
Html,
Json,
Text,
Csv,
}
impl ReportFormat {
pub fn from_extension(filename: &str) -> Self {
if filename.ends_with(".json") {
ReportFormat::Json
} else if filename.ends_with(".html") || filename.ends_with(".htm") {
ReportFormat::Html
} else if filename.ends_with(".txt") {
ReportFormat::Text
} else if filename.ends_with(".csv") {
ReportFormat::Csv
} else {
ReportFormat::Json
}
}
}
pub struct ReportWriter {
file: Arc<Mutex<File>>,
format: ReportFormat,
first_entry: Arc<Mutex<bool>>, }
impl ReportWriter {
pub fn new(report_path: &str) -> Result<Self> {
let format = ReportFormat::from_extension(report_path);
let mut file = File::create(report_path)
.with_context(|| format!("Failed to create report file: {report_path}"))?;
match format {
ReportFormat::Json => {
writeln!(file, "[")?;
}
ReportFormat::Html => {
writeln!(file, "{}", Self::html_prefix())?;
}
ReportFormat::Csv => {
writeln!(
file,
"file,pattern,line_number,start_pos,end_pos,line_content,matched_text"
)?;
}
ReportFormat::Text => {
writeln!(file, "Guardy Security Scan Report")?;
writeln!(
file,
"Generated at: {}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
)?;
writeln!(file, "Version: {}", env!("CARGO_PKG_VERSION"))?;
writeln!(file, "{}", "=".repeat(50))?;
writeln!(file)?;
}
}
Ok(Self {
file: Arc::new(Mutex::new(file)),
format,
first_entry: Arc::new(Mutex::new(true)),
})
}
pub fn get_atomic_handle(&self) -> AtomicHandle {
AtomicHandle {
file: self.file.clone(),
format: self.format,
first_entry: self.first_entry.clone(),
}
}
pub fn finalize(self) -> Result<()> {
let mut file = self.file.lock().unwrap();
match self.format {
ReportFormat::Json => {
writeln!(file, "]")?;
}
ReportFormat::Html => {
writeln!(file, "{}", Self::html_suffix())?;
}
ReportFormat::Text | ReportFormat::Csv => {
}
}
file.flush()?;
Ok(())
}
fn html_prefix() -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Guardy Security Scan Report</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
.header {{ border-bottom: 2px solid #333; padding-bottom: 10px; }}
.match {{ border: 1px solid #ddd; margin: 10px 0; padding: 10px; }}
.file-path {{ font-weight: bold; color: #0066cc; }}
.pattern {{ color: #cc6600; }}
.line-number {{ color: #666; }}
.content {{ background: #f5f5f5; padding: 5px; font-family: monospace; }}
</style>
</head>
<body>
<div class="header">
<h1>🔍 Guardy Security Scan Report</h1>
<p>Generated at: {}</p>
<p>Version: {}</p>
</div>
<div class="matches">
"#,
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs(),
env!("CARGO_PKG_VERSION")
)
}
fn html_suffix() -> &'static str {
r#" </div>
</body>
</html>"#
}
}
#[derive(Clone)]
pub struct AtomicHandle {
file: Arc<Mutex<File>>,
format: ReportFormat,
first_entry: Arc<Mutex<bool>>,
}
impl AtomicHandle {
pub fn write_matches(&self, matches: &[SecretMatch], redacted: bool) -> Result<()> {
if matches.is_empty() {
return Ok(());
}
let file_path = matches[0].file_path.as_ref();
let content = match self.format {
ReportFormat::Json => self.format_json_matches(matches, file_path, redacted)?,
ReportFormat::Html => self.format_html_matches(matches, file_path, redacted),
ReportFormat::Text => self.format_text_matches(matches, file_path, redacted),
ReportFormat::Csv => self.format_csv_matches(matches, file_path, redacted),
};
let mut file = self.file.lock().unwrap();
write!(file, "{content}")?;
file.flush()?;
Ok(())
}
fn format_json_matches(
&self,
matches: &[SecretMatch],
file_path: &Path,
redacted: bool,
) -> Result<String> {
let mut is_first = self.first_entry.lock().unwrap();
let comma = if *is_first {
*is_first = false;
""
} else {
","
};
let file_entry = json!({
"file": file_path.to_string_lossy(),
"matches": matches.iter().map(|m| {
json!({
"pattern": m.pattern.name,
"line_number": m.line_number,
"start_pos": m.start_pos,
"end_pos": m.end_pos,
"line_content": if redacted { "[REDACTED]" } else { &m.line_content },
"matched_text": if redacted { "[REDACTED]" } else { &m.matched_text },
"entropy": m.entropy
})
}).collect::<Vec<_>>()
});
Ok(format!(
"{}{}",
comma,
serde_json::to_string_pretty(&file_entry)?
))
}
fn format_html_matches(
&self,
matches: &[SecretMatch],
file_path: &Path,
redacted: bool,
) -> String {
let mut html = format!(
r#" <div class="match">
<div class="file-path">📄 {}</div>
"#,
file_path.display()
);
for m in matches {
let content = if redacted {
"[REDACTED]"
} else {
&m.line_content
};
let matched = if redacted {
"[REDACTED]"
} else {
&m.matched_text
};
html.push_str(&format!(
r#" <div class="match-detail">
<span class="pattern">🔍 {}</span>
<span class="line-number">Line {}:{}</span><br>
<div class="content">{}</div>
<div class="matched">Matched: {} (length: {})</div>
</div>
"#,
m.pattern.name,
m.line_number,
m.start_pos,
content,
matched,
m.end_pos - m.start_pos
));
}
html.push_str(" </div>\n");
html
}
fn format_text_matches(
&self,
matches: &[SecretMatch],
file_path: &Path,
redacted: bool,
) -> String {
let mut text = format!("📄 {}\n", file_path.display());
for m in matches {
let content = if redacted {
"[REDACTED]"
} else {
&m.line_content
};
text.push_str(&format!(
" 🔍 Line {}:{} {} {}\n",
m.line_number,
m.start_pos,
m.pattern.name,
content.trim()
));
}
text.push('\n');
text
}
fn format_csv_matches(
&self,
matches: &[SecretMatch],
file_path: &Path,
redacted: bool,
) -> String {
let mut csv = String::new();
for m in matches {
let content = if redacted {
"[REDACTED]"
} else {
&m.line_content
};
let matched = if redacted {
"[REDACTED]"
} else {
&m.matched_text
};
csv.push_str(&format!(
"{},{},{},{},{},{},{}\n",
Self::escape_csv_field(&file_path.to_string_lossy()),
Self::escape_csv_field(&m.pattern.name),
m.line_number,
m.start_pos,
m.end_pos,
Self::escape_csv_field(content.trim()),
Self::escape_csv_field(matched)
));
}
csv
}
fn escape_csv_field(field: &str) -> String {
if field.contains(',') || field.contains('"') || field.contains('\n') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
}