xll-utils 0.1.0

PE/COFF parsing and export verification utilities for Excel XLL development
Documentation
//! Export enumeration and verification utilities.
//!
//! Provides functions for listing DLL exports, verifying that expected
//! exports are present, and computing diffs between export lists.

use std::collections::HashSet;
use std::path::Path;

use crate::error::Result;
use crate::pe::parse_pe_file;
use crate::types::{ExportDiff, ExportInfo, NameMismatch, PeFile, VerificationReport};

/// List all exports from a DLL/XLL file.
///
/// # Examples
///
/// ```no_run
/// use xll_utils::exports::list_dll_exports;
///
/// let exports = list_dll_exports("my_xll.xll").unwrap();
/// for exp in &exports {
///     if let Some(name) = &exp.name {
///         println!("{name} (ordinal {})", exp.ordinal);
///     }
/// }
/// ```
pub fn list_dll_exports(path: impl AsRef<Path>) -> Result<Vec<ExportInfo>> {
    let pe = parse_pe_file(path.as_ref())?;
    Ok(pe.exports().to_vec())
}

/// List export names from a DLL/XLL file (sorted).
pub fn list_export_names(path: impl AsRef<Path>) -> Result<Vec<String>> {
    let pe = parse_pe_file(path.as_ref())?;
    Ok(pe.export_names())
}

/// Verify that a DLL/XLL contains all expected exports.
///
/// # Examples
///
/// ```no_run
/// use xll_utils::exports::verify_dll_exports;
///
/// let report = verify_dll_exports("my_xll.xll", &["xlAutoOpen", "xlAutoFree12"]).unwrap();
/// assert!(report.complete, "Missing exports: {:?}", report.missing);
/// ```
pub fn verify_dll_exports(
    path: impl AsRef<Path>,
    expected: &[&str],
) -> Result<VerificationReport> {
    let pe = parse_pe_file(path.as_ref())?;
    Ok(verify_pe_exports(&pe, expected))
}

/// Verify exports from an already-parsed PE file.
///
/// This is a pure computation on an already-parsed `PeFile` and always succeeds.
///
/// # Examples
///
/// ```no_run
/// use xll_utils::pe::parse_pe_file;
/// use xll_utils::exports::verify_pe_exports;
///
/// let pe = parse_pe_file("my_xll.xll").unwrap();
/// let report = verify_pe_exports(&pe, &["xlAutoOpen"]);
/// println!("complete: {}", report.complete);
/// ```
pub fn verify_pe_exports(pe: &PeFile, expected: &[&str]) -> VerificationReport {
    let export_names: HashSet<&str> = pe
        .exports
        .iter()
        .filter_map(|e| e.name.as_deref())
        .collect();

    let expected_set: HashSet<&str> = expected.iter().copied().collect();

    let mut found = Vec::new();
    let mut missing = Vec::new();

    for &name in expected {
        if export_names.contains(name) {
            found.push(name.to_string());
        } else {
            missing.push(name.to_string());
        }
    }

    let mut unexpected: Vec<String> = export_names
        .iter()
        .filter(|n| !expected_set.contains(*n))
        .map(|s| s.to_string())
        .collect();
    unexpected.sort();

    let complete = missing.is_empty();

    VerificationReport {
        dll_path: pe.path.clone(),
        total_exports: pe.exports.len(),
        found,
        missing,
        complete,
        unexpected,
        mismatches: Vec::new(),
        architecture: pe.architecture,
    }
}

/// Verify exports with case-insensitive name matching.
///
/// When `case_sensitive` is `false`, exports are matched ignoring case and
/// any case mismatches are reported in `VerificationReport::mismatches`.
pub fn verify_dll_exports_strict(
    path: impl AsRef<Path>,
    expected: &[&str],
    case_sensitive: bool,
) -> Result<VerificationReport> {
    let pe = parse_pe_file(path.as_ref())?;
    let mut report = verify_pe_exports(&pe, expected);

    if !case_sensitive {
        // Re-evaluate found/missing using case-insensitive matching.
        // Use a Vec of (lowercase_name, &ExportInfo) to avoid HashMap
        // collisions when multiple exports differ only by case.
        let exports_lower: Vec<(String, &ExportInfo)> = pe
            .exports
            .iter()
            .filter_map(|e| e.name.as_deref().map(|n| (n.to_lowercase(), e)))
            .collect();

        let mut found = Vec::new();
        let mut missing = Vec::new();
        let mut mismatches = Vec::new();

        for &name in expected {
            let key = name.to_lowercase();
            // Find the first matching export (case-insensitive).
            if let Some((_, export)) = exports_lower.iter().find(|(lc, _)| *lc == key) {
                found.push(name.to_string());
                // Check for case mismatch.
                let actual_name = export
                    .name
                    .as_deref()
                    .expect("export has name (filtered above)");
                if actual_name != name {
                    mismatches.push(NameMismatch {
                        expected: name.to_string(),
                        actual: actual_name.to_string(),
                        ordinal: export.ordinal,
                    });
                }
            } else {
                missing.push(name.to_string());
            }
        }

        let expected_lower_set: HashSet<String> =
            expected.iter().map(|s| s.to_lowercase()).collect();
        let mut unexpected: Vec<String> = pe
            .exports
            .iter()
            .filter_map(|e| e.name.as_deref())
            .filter(|n| !expected_lower_set.contains(&n.to_lowercase()))
            .map(|s| s.to_string())
            .collect();
        unexpected.sort();

        report.found = found;
        report.missing = missing;
        report.complete = report.missing.is_empty();
        report.unexpected = unexpected;
        report.mismatches = mismatches;
    }

    Ok(report)
}

/// Compute differences between expected and actual export lists.
///
/// # Examples
///
/// ```
/// use xll_utils::exports::export_diff;
/// use xll_utils::ExportInfo;
///
/// let actual = vec![
///     ExportInfo { name: Some("foo".into()), ordinal: 1, is_forwarded: false, forward_to: None, relative_address: Some(0x1000) },
///     ExportInfo { name: Some("bar".into()), ordinal: 2, is_forwarded: false, forward_to: None, relative_address: Some(0x2000) },
/// ];
/// let diff = export_diff(&["foo", "baz"], &actual);
/// assert_eq!(diff.common, vec!["foo"]);
/// assert_eq!(diff.missing, vec!["baz"]);
/// assert_eq!(diff.extra, vec!["bar"]);
/// ```
pub fn export_diff(expected: &[&str], actual: &[ExportInfo]) -> ExportDiff {
    let actual_names: HashSet<&str> = actual
        .iter()
        .filter_map(|e| e.name.as_deref())
        .collect();

    let expected_set: HashSet<&str> = expected.iter().copied().collect();

    let missing = expected
        .iter()
        .filter(|e| !actual_names.contains(*e))
        .map(|s| s.to_string())
        .collect();

    let mut extra: Vec<String> = actual_names
        .iter()
        .filter(|e| !expected_set.contains(*e))
        .map(|s| s.to_string())
        .collect();
    extra.sort();

    let common = expected
        .iter()
        .filter(|e| actual_names.contains(*e))
        .map(|s| s.to_string())
        .collect();

    ExportDiff {
        missing,
        extra,
        common,
    }
}