sbpf-assembler 0.2.4

Assembler for SBPF (Solana BPF) assembly language
Documentation
use {
    serde::{Deserialize, Serialize},
    std::{collections::BTreeMap, env, fs, path::PathBuf},
};

#[derive(Debug, Deserialize, Serialize)]
struct Manifest {
    #[serde(default)]
    cases: BTreeMap<String, Case>,
}

#[derive(Debug, Deserialize, Serialize)]
struct Case {
    file: String,
    #[serde(default)]
    hash: String,
    #[serde(default)]
    debug_hash: String,
    #[serde(default)]
    hash_v0: String,
    #[serde(default)]
    debug_hash_v0: String,
}

#[derive(Debug)]
enum IssueKind {
    HashMismatch,
    DebugHashMismatch,
    AssemblerError,
}
#[derive(Debug)]
struct Issue {
    kind: IssueKind,
    name: String,
    file: String,
    arch: sbpf_assembler::SbpfArch,
    expected: Option<String>,
    actual: Option<String>,
    note: Option<String>,
}

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

fn read_manifest() -> Manifest {
    let manifest_path = fixtures_dir().join("index.toml");
    let content = fs::read_to_string(&manifest_path)
        .unwrap_or_else(|e| panic!("failed to read {}: {}", manifest_path.display(), e));
    toml::from_str(&content).expect("failed to parse fixtures/index.toml")
}

fn read_source(case_file: &str) -> String {
    let path = fixtures_dir().join(case_file);
    fs::read_to_string(&path).unwrap_or_else(|e| panic!("failed to read {}: {}", path.display(), e))
}

fn write_manifest(manifest: &Manifest) {
    let manifest_path = fixtures_dir().join("index.toml");
    let content = toml::to_string_pretty(manifest)
        .unwrap_or_else(|e| panic!("failed to serialize manifest: {:?}", e));
    fs::write(&manifest_path, content)
        .unwrap_or_else(|e| panic!("failed to write {}: {}", manifest_path.display(), e));
}

fn hash_bytes(bytes: &[u8]) -> String {
    blake3::hash(bytes).to_hex().to_string()
}

fn expected_hash_mut(case: &mut Case, arch: sbpf_assembler::SbpfArch) -> &mut String {
    match arch {
        sbpf_assembler::SbpfArch::V0 => &mut case.hash_v0,
        sbpf_assembler::SbpfArch::V3 => &mut case.hash,
    }
}

fn expected_debug_hash_mut(case: &mut Case, arch: sbpf_assembler::SbpfArch) -> &mut String {
    match arch {
        sbpf_assembler::SbpfArch::V0 => &mut case.debug_hash_v0,
        sbpf_assembler::SbpfArch::V3 => &mut case.debug_hash,
    }
}

#[test]
fn test_regression() {
    let mut manifest = read_manifest();
    assert!(
        !manifest.cases.is_empty(),
        "fixtures/index.toml must define at least one case"
    );

    let update_hashes = env::var("UPDATE_HASHES")
        .ok()
        .filter(|v| v == "1")
        .is_some();
    let mut any_missing_or_mismatch = false;

    let mut issues: Vec<Issue> = Vec::new();

    let mut updated_entries = 0usize;
    for (name, case) in manifest.cases.iter_mut() {
        let case_file = case.file.clone();
        let source = read_source(&case_file);

        for arch in [sbpf_assembler::SbpfArch::V0, sbpf_assembler::SbpfArch::V3] {
            let is_v0_jump32_case =
                matches!(arch, sbpf_assembler::SbpfArch::V0) && case_file.starts_with("jump32_");

            // Test assembly
            let assembler = sbpf_assembler::Assembler::new(
                sbpf_assembler::AssemblerOption::default().with_arch(arch),
            );
            let assemble_result = assembler.assemble(&source);
            if is_v0_jump32_case {
                assert!(
                    assemble_result.is_err(),
                    "expected fixture '{}' to fail assembly",
                    case_file
                );
            } else {
                let actual = match assemble_result {
                    Ok(bytes) => hash_bytes(&bytes),
                    Err(e) => {
                        any_missing_or_mismatch = true;
                        issues.push(Issue {
                            kind: IssueKind::AssemblerError,
                            name: name.clone(),
                            file: case_file.clone(),
                            arch,
                            expected: None,
                            actual: None,
                            note: Some(format!("assembler failed: {:?}", e)),
                        });
                        continue;
                    }
                };

                let expected = expected_hash_mut(case, arch);
                if actual != *expected {
                    if update_hashes && expected.is_empty() {
                        // Only update the hash if it's empty.
                        *expected = actual.clone();
                        updated_entries += 1;
                    } else {
                        any_missing_or_mismatch = true;
                        issues.push(Issue {
                            kind: IssueKind::HashMismatch,
                            name: name.clone(),
                            file: case_file.clone(),
                            arch,
                            expected: Some(expected.clone()),
                            actual: Some(actual.clone()),
                            note: None,
                        });
                    }
                }
            }

            // Test assembly (debug)
            let debug_assembler = sbpf_assembler::Assembler::new(
                sbpf_assembler::AssemblerOption::default()
                    .with_arch(arch)
                    .with_debug_mode(sbpf_assembler::DebugMode {
                        filename: case_file.clone(),
                        directory: "/test".to_string(),
                    }),
            );
            let debug_result = debug_assembler.assemble(&source);

            if is_v0_jump32_case {
                assert!(
                    debug_result.is_err(),
                    "expected fixture '{}' to fail debug assembly",
                    case_file
                );
            } else {
                let debug_actual = match debug_result {
                    Ok(bytes) => hash_bytes(&bytes),
                    Err(e) => {
                        any_missing_or_mismatch = true;
                        issues.push(Issue {
                            kind: IssueKind::AssemblerError,
                            name: name.clone(),
                            file: case_file.clone(),
                            arch,
                            expected: None,
                            actual: None,
                            note: Some(format!("debug assembler failed: {:?}", e)),
                        });
                        continue;
                    }
                };

                let expected_debug = expected_debug_hash_mut(case, arch);
                if debug_actual != *expected_debug {
                    if update_hashes && expected_debug.is_empty() {
                        // Only update the debug_hash if it's empty.
                        *expected_debug = debug_actual.clone();
                        updated_entries += 1;
                    } else {
                        any_missing_or_mismatch = true;
                        issues.push(Issue {
                            kind: IssueKind::DebugHashMismatch,
                            name: name.clone(),
                            file: case_file.clone(),
                            arch,
                            expected: Some(expected_debug.clone()),
                            actual: Some(debug_actual.clone()),
                            note: None,
                        });
                    }
                }
            }
        }
    }

    if update_hashes && updated_entries > 0 {
        write_manifest(&manifest);
    }

    if any_missing_or_mismatch {
        let mut mismatched = 0usize;
        let mut debug_mismatched = 0usize;
        let mut asmerr = 0usize;

        eprintln!("\n===== Regression Report =====");
        for issue in &issues {
            match issue.kind {
                IssueKind::HashMismatch => {
                    mismatched += 1;
                    eprintln!(
                        "[Mismatch] arch='{:?}' case='{}' file='{}' expected={} actual={}",
                        issue.arch,
                        issue.name,
                        issue.file,
                        issue.expected.as_deref().unwrap_or("<none>"),
                        issue.actual.as_deref().unwrap_or("<none>")
                    );
                }
                IssueKind::DebugHashMismatch => {
                    debug_mismatched += 1;
                    eprintln!(
                        "[Debug Mismatch] arch='{:?}' case='{}' file='{}' expected={} actual={}",
                        issue.arch,
                        issue.name,
                        issue.file,
                        issue.expected.as_deref().unwrap_or("<none>"),
                        issue.actual.as_deref().unwrap_or("<none>")
                    );
                }
                IssueKind::AssemblerError => {
                    asmerr += 1;
                    eprintln!(
                        "[Assembler Error] arch='{:?}' case='{}' file='{}' note={}",
                        issue.arch,
                        issue.name,
                        issue.file,
                        issue.note.as_deref().unwrap_or("")
                    );
                }
            }
        }
        eprintln!(
            "===== Summary: total={} mismatched={} debug_mismatched={} assembler-error={} =====\n",
            issues.len(),
            mismatched,
            debug_mismatched,
            asmerr
        );

        panic!("regressions detected ({}).", issues.len());
    }
}