fsci-conformance 0.3.0

Differential conformance testing harness for FrankenSciPy
#![forbid(unsafe_code)]

use blake3::hash;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Clone)]
struct Config {
    input_dir: PathBuf,
    output_dir: PathBuf,
    regression_dir: PathBuf,
    retention_days: u64,
    file_beads: bool,
}

#[derive(Debug, Clone, Serialize)]
struct FindingReport {
    finding_id: String,
    stack_hash: String,
    classification: String,
    source_files: Vec<String>,
    promoted_fixture: String,
    created_at_epoch_seconds: u64,
    retention_days: u64,
    bead_id: Option<String>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct TriageIndex {
    #[serde(default)]
    bead_by_stack_hash: BTreeMap<String, String>,
}

#[derive(Debug, Serialize)]
struct TriageSummary {
    schema_version: u32,
    generated_at_epoch_seconds: u64,
    input_dir: String,
    output_dir: String,
    regression_dir: String,
    unique_findings: usize,
    source_files_processed: usize,
    reports: Vec<FindingReport>,
}

fn now_epoch_seconds() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |duration| duration.as_secs())
}

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

fn default_config() -> Config {
    let root = repo_root();
    Config {
        input_dir: root.join("fuzz/artifacts"),
        output_dir: root.join("crates/fsci-conformance/fixtures/adversarial/triage"),
        regression_dir: root.join("crates/fsci-conformance/fixtures/regressions/fuzz"),
        retention_days: 90,
        file_beads: true,
    }
}

fn parse_args() -> Config {
    let mut config = default_config();
    let mut args = std::env::args().skip(1);
    while let Some(flag) = args.next() {
        match flag.as_str() {
            "--input-dir" => {
                if let Some(path) = args.next() {
                    config.input_dir = PathBuf::from(path);
                    continue;
                }
                eprintln!("missing value for --input-dir");
                std::process::exit(2);
            }
            "--output-dir" => {
                if let Some(path) = args.next() {
                    config.output_dir = PathBuf::from(path);
                    continue;
                }
                eprintln!("missing value for --output-dir");
                std::process::exit(2);
            }
            "--regression-dir" => {
                if let Some(path) = args.next() {
                    config.regression_dir = PathBuf::from(path);
                    continue;
                }
                eprintln!("missing value for --regression-dir");
                std::process::exit(2);
            }
            "--retention-days" => {
                if let Some(days) = args.next() {
                    match days.parse::<u64>() {
                        Ok(value) => config.retention_days = value,
                        Err(_) => {
                            eprintln!("invalid value for --retention-days: {days}");
                            std::process::exit(2);
                        }
                    }
                    continue;
                }
                eprintln!("missing value for --retention-days");
                std::process::exit(2);
            }
            "--no-file-beads" => {
                config.file_beads = false;
            }
            "--file-beads" => {
                config.file_beads = true;
            }
            "--help" | "-h" => {
                print_help();
                std::process::exit(0);
            }
            _ => {
                eprintln!("unrecognized argument: {flag}");
                print_help();
                std::process::exit(2);
            }
        }
    }
    config
}

fn print_help() {
    println!(
        "Usage: cargo run -p fsci-conformance --bin fuzz_triage -- [OPTIONS]

Options:
  --input-dir <PATH>       Source crash artifacts directory (default: fuzz/artifacts)
  --output-dir <PATH>      Triage report directory
  --regression-dir <PATH>  Regression fixture promotion directory
  --retention-days <N>     Retention horizon for raw crashes (default: 90)
  --file-beads             Auto-file beads labeled fuzz-finding (default: on)
  --no-file-beads          Disable automatic bead filing"
    );
}

fn looks_like_crash_file(path: &Path) -> bool {
    let name = path.file_name().and_then(OsStr::to_str).unwrap_or_default();
    name.starts_with("crash-") || name.starts_with("oom-") || name.starts_with("timeout-")
}

fn walk_crash_files(root: &Path, files: &mut Vec<PathBuf>) -> io::Result<()> {
    if !root.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(root)? {
        let entry = entry?;
        let path = entry.path();
        if path.is_dir() {
            walk_crash_files(&path, files)?;
            continue;
        }
        if looks_like_crash_file(&path) {
            files.push(path);
        }
    }
    Ok(())
}

fn signature_text(bytes: &[u8]) -> String {
    let text = String::from_utf8_lossy(bytes);
    let mut picked = Vec::new();
    for line in text.lines() {
        if line.contains("SUMMARY:")
            || line.contains("panicked at")
            || line.contains("thread '")
            || line.contains("stack")
        {
            picked.push(line.trim().to_owned());
        }
        if picked.len() >= 8 {
            break;
        }
    }
    if picked.is_empty() {
        picked.extend(text.lines().take(8).map(|line| line.trim().to_owned()));
    }
    if picked.is_empty() {
        return "empty-crash-artifact".to_owned();
    }
    picked.join("\n")
}

fn classification_for(bytes: &[u8]) -> &'static str {
    let lower = String::from_utf8_lossy(bytes).to_ascii_lowercase();
    if lower.contains("nan") || lower.contains("inf") || lower.contains("non-finite") {
        return "non_finite_input";
    }
    if lower.contains("shape") || lower.contains("dimension") || lower.contains("mismatch") {
        return "shape_mismatch";
    }
    if lower.contains("overflow") || lower.contains("oom") || lower.contains("allocation") {
        return "resource_exhaustion";
    }
    if lower.contains("compat") || lower.contains("version") {
        return "compatibility_drift";
    }
    "unknown"
}

fn load_index(path: &Path) -> TriageIndex {
    let Ok(raw) = fs::read_to_string(path) else {
        return TriageIndex::default();
    };
    serde_json::from_str(&raw).unwrap_or_default()
}

fn save_index(path: &Path, index: &TriageIndex) -> io::Result<()> {
    let payload =
        serde_json::to_string_pretty(index).map_err(|error| io::Error::other(error.to_string()))?;
    fs::write(path, payload)
}

fn file_fuzz_finding_bead(
    stack_hash: &str,
    classification: &str,
    sources: usize,
) -> Option<String> {
    let title = format!("[FUZZ-FINDING] {classification} {stack_hash}");
    let description = format!(
        "Auto-filed by fuzz triage.\n\nstack_hash: {stack_hash}\nclassification: {classification}\nsource_crashes: {sources}\n"
    );
    let output = Command::new("br")
        .args([
            "create",
            "--title",
            &title,
            "--type",
            "bug",
            "--priority",
            "1",
            "--labels",
            "fuzz-finding,security",
            "--description",
            &description,
            "--json",
        ])
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let stdout = String::from_utf8_lossy(&output.stdout);
    let parsed = serde_json::from_str::<serde_json::Value>(&stdout).ok()?;
    parsed
        .get("id")
        .and_then(serde_json::Value::as_str)
        .map(ToOwned::to_owned)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = parse_args();
    fs::create_dir_all(&config.output_dir)?;
    fs::create_dir_all(&config.regression_dir)?;

    let mut files = Vec::new();
    walk_crash_files(&config.input_dir, &mut files)?;

    let mut grouped: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
    let mut bytes_by_hash: BTreeMap<String, Vec<u8>> = BTreeMap::new();
    for file in &files {
        let bytes = fs::read(file)?;
        let signature = signature_text(&bytes);
        let stack_hash = hash(signature.as_bytes()).to_hex().to_string();
        grouped
            .entry(stack_hash.clone())
            .or_default()
            .push(file.clone());
        bytes_by_hash.entry(stack_hash).or_insert(bytes);
    }

    let index_path = config.output_dir.join("triage_index.json");
    let mut index = load_index(&index_path);

    let now = now_epoch_seconds();
    let mut reports = Vec::new();
    for (stack_hash, group) in grouped {
        let sample_bytes = bytes_by_hash.get(&stack_hash).cloned().unwrap_or_default();
        let classification = classification_for(&sample_bytes).to_owned();
        let source_files = group
            .iter()
            .map(|path| path.to_string_lossy().to_string())
            .collect::<Vec<_>>();
        let finding_id = format!("finding-{stack_hash}");

        let fixture_path = config.regression_dir.join(format!("{stack_hash}.bin"));
        if let Some(primary) = group.first() {
            let _ = fs::copy(primary, &fixture_path);
        }

        let bead_id = if let Some(existing) = index.bead_by_stack_hash.get(&stack_hash) {
            Some(existing.clone())
        } else if config.file_beads {
            file_fuzz_finding_bead(&stack_hash, &classification, source_files.len())
        } else {
            None
        };
        if let Some(issue_id) = bead_id.clone() {
            index
                .bead_by_stack_hash
                .insert(stack_hash.clone(), issue_id);
        }

        let report = FindingReport {
            finding_id: finding_id.clone(),
            stack_hash: stack_hash.clone(),
            classification,
            source_files,
            promoted_fixture: fixture_path.to_string_lossy().to_string(),
            created_at_epoch_seconds: now,
            retention_days: config.retention_days,
            bead_id,
        };
        let report_path = config.output_dir.join(format!("{finding_id}.json"));
        let report_payload = serde_json::to_string_pretty(&report)?;
        fs::write(report_path, report_payload)?;
        reports.push(report);
    }

    save_index(&index_path, &index)?;

    let summary = TriageSummary {
        schema_version: 1,
        generated_at_epoch_seconds: now,
        input_dir: config.input_dir.to_string_lossy().to_string(),
        output_dir: config.output_dir.to_string_lossy().to_string(),
        regression_dir: config.regression_dir.to_string_lossy().to_string(),
        unique_findings: reports.len(),
        source_files_processed: files.len(),
        reports,
    };
    let summary_path = config.output_dir.join("summary.json");
    fs::write(summary_path, serde_json::to_string_pretty(&summary)?)?;

    println!(
        "triaged {} crash artifact(s) into {} unique finding(s)",
        summary.source_files_processed, summary.unique_findings
    );
    Ok(())
}