use anyhow::{Context, Result};
use crossbeam_channel::{bounded, Sender, Receiver};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::PathBuf;
use std::thread;
use walkdir::WalkDir;
use crate::scanner::{scan_certificates, FoundCert};
#[derive(Debug, Clone)]
pub struct ScanConfig {
pub recursive: bool,
pub follow_symlinks: bool,
pub max_depth: Option<usize>,
pub extensions: Option<Vec<String>>,
pub threads: usize,
pub max_file_size: u64,
pub unique_only: bool,
pub mark_duplicates: bool,
pub verbose: bool,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
recursive: false,
follow_symlinks: false,
max_depth: None,
extensions: None,
threads: num_cpus::get().max(1),
max_file_size: 512 * 1024 * 1024, unique_only: false,
mark_duplicates: false,
verbose: false,
}
}
}
#[derive(Debug, Clone)]
pub struct FileJob {
pub path: PathBuf,
pub size: u64,
}
#[derive(Debug)]
pub struct ScanResult {
pub path: PathBuf,
pub file_size: u64,
pub certs: Vec<FoundCert>,
}
#[derive(Debug, Clone, Serialize)]
pub struct FirstSeen {
pub global_index: u64,
#[serde(serialize_with = "serialize_path")]
pub path: PathBuf,
pub offset: usize,
pub size: usize,
pub count: u64,
}
fn serialize_path<S>(path: &PathBuf, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&path.display().to_string())
}
#[derive(Debug, Clone)]
pub struct CertWithDuplicateInfo {
pub cert: FoundCert,
pub path: PathBuf,
pub global_index: u64,
pub is_duplicate: bool,
pub duplicate_of: Option<FirstSeen>,
}
pub struct DuplicateTracker {
seen: HashMap<[u8; 32], FirstSeen>,
global_index: u64,
}
impl DuplicateTracker {
pub fn new() -> Self {
Self {
seen: HashMap::new(),
global_index: 0,
}
}
pub fn process(&mut self, cert: FoundCert, path: PathBuf) -> CertWithDuplicateInfo {
let digest = cert.sha256_digest();
if let Some(first_seen) = self.seen.get_mut(&digest) {
first_seen.count += 1;
CertWithDuplicateInfo {
cert,
path,
global_index: self.global_index,
is_duplicate: true,
duplicate_of: Some(first_seen.clone()),
}
} else {
let global_index = self.global_index;
let first_seen = FirstSeen {
global_index,
path: path.clone(),
offset: cert.offset,
size: cert.raw_range_len,
count: 1,
};
self.seen.insert(digest, first_seen);
CertWithDuplicateInfo {
cert,
path,
global_index,
is_duplicate: false,
duplicate_of: None,
}
}
}
pub fn increment_index(&mut self) {
self.global_index += 1;
}
}
pub fn enumerate_files(
paths: Vec<PathBuf>,
config: &ScanConfig,
) -> Result<Vec<FileJob>> {
let mut jobs = Vec::new();
let mut seen_inodes = HashSet::new();
for path in paths {
if path.is_file() {
let metadata = fs::metadata(&path)
.with_context(|| format!("Failed to read metadata for {:?}", path))?;
jobs.push(FileJob {
path,
size: metadata.len(),
});
} else if path.is_dir() {
if !config.recursive {
anyhow::bail!(
"Input path {:?} is a directory. Use -R/--recursive to scan directories.",
path
);
}
let walker = WalkDir::new(&path)
.follow_links(config.follow_symlinks)
.max_depth(config.max_depth.unwrap_or(usize::MAX));
for entry in walker {
match entry {
Ok(entry) => {
if !entry.file_type().is_file() {
continue;
}
let entry_path = entry.path();
if let Some(ref exts) = config.extensions {
if let Some(ext) = entry_path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if !exts.iter().any(|e| e.to_lowercase() == ext_str) {
if config.verbose {
eprintln!("Skipping {:?}: extension not in filter", entry_path);
}
continue;
}
} else {
if config.verbose {
eprintln!("Skipping {:?}: no extension", entry_path);
}
continue;
}
}
if let Ok(metadata) = entry.metadata() {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let inode_id = (metadata.dev(), metadata.ino());
if !seen_inodes.insert(inode_id) {
if config.verbose {
eprintln!("Skipping {:?}: hard link duplicate", entry_path);
}
continue;
}
}
let file_size = metadata.len();
if file_size > config.max_file_size {
if config.verbose {
eprintln!(
"Skipping {:?}: size {} exceeds limit {}",
entry_path, file_size, config.max_file_size
);
}
continue;
}
jobs.push(FileJob {
path: entry_path.to_path_buf(),
size: file_size,
});
}
}
Err(e) => {
if config.verbose {
eprintln!("Warning: Failed to read directory entry: {}", e);
}
}
}
}
} else {
anyhow::bail!("Input path {:?} is neither a file nor a directory", path);
}
}
Ok(jobs)
}
pub fn scan_files_parallel(
jobs: Vec<FileJob>,
config: &ScanConfig,
mut result_handler: impl FnMut(CertWithDuplicateInfo) -> Result<()>,
) -> Result<()> {
let (work_tx, work_rx): (Sender<FileJob>, Receiver<FileJob>) = bounded(config.threads * 2);
let (result_tx, result_rx): (Sender<Result<ScanResult>>, Receiver<Result<ScanResult>>) =
bounded(config.threads * 2);
let mut workers = Vec::new();
for worker_id in 0..config.threads {
let work_rx = work_rx.clone();
let result_tx = result_tx.clone();
let verbose = config.verbose;
let worker = thread::spawn(move || {
if verbose {
eprintln!("Worker {} started", worker_id);
}
while let Ok(job) = work_rx.recv() {
let result = scan_file_worker(&job, verbose);
if result_tx.send(result).is_err() {
break;
}
}
if verbose {
eprintln!("Worker {} finished", worker_id);
}
});
workers.push(worker);
}
drop(work_rx);
drop(result_tx);
let producer = {
let work_tx = work_tx.clone();
let verbose = config.verbose;
thread::spawn(move || {
if verbose {
eprintln!("Enqueuing {} files for scanning", jobs.len());
}
for job in jobs {
if work_tx.send(job).is_err() {
break;
}
}
if verbose {
eprintln!("All files enqueued");
}
})
};
drop(work_tx);
let mut tracker = DuplicateTracker::new();
let mut file_count = 0;
let mut total_certs = 0;
for result in result_rx {
match result {
Ok(scan_result) => {
file_count += 1;
let file_cert_count = scan_result.certs.len();
total_certs += file_cert_count;
if config.verbose {
eprintln!(
"Processed {}: {} certificate(s)",
scan_result.path.display(),
file_cert_count
);
}
for cert in scan_result.certs {
let cert_info = tracker.process(cert, scan_result.path.clone());
if config.unique_only && cert_info.is_duplicate {
if config.verbose {
if let Some(ref first) = cert_info.duplicate_of {
eprintln!(
"Skipping duplicate: SHA-256 {} (first seen in {} at offset 0x{:X})",
cert_info.cert.sha256_hex(),
first.path.display(),
first.offset
);
}
}
} else {
result_handler(cert_info)?;
}
tracker.increment_index();
}
}
Err(e) => {
if config.verbose {
eprintln!("Error scanning file: {}", e);
}
}
}
}
producer.join().ok();
for worker in workers {
worker.join().ok();
}
if config.verbose {
eprintln!(
"Scan complete: {} files processed, {} total certificates found",
file_count, total_certs
);
}
Ok(())
}
fn scan_file_worker(job: &FileJob, verbose: bool) -> Result<ScanResult> {
let data = fs::read(&job.path)
.with_context(|| format!("Failed to read file {:?}", job.path))?;
if verbose {
eprintln!("Scanning {} ({} bytes)", job.path.display(), data.len());
}
let certs = scan_certificates(&data, verbose)?;
Ok(ScanResult {
path: job.path.clone(),
file_size: job.size,
certs,
})
}