idakit 0.2.0

Idiomatic Rust bindings for IDA Pro's idalib kernel
Documentation
//! Fault injection: feed `open` bad and corrupt inputs and assert the kernel rejects each
//! with a typed `Err` while the process survives to run the assertion. nextest isolates every
//! test in its own process, so a kernel fatal that escapes the facade's traps (a crash, an
//! `abort`, or a deadlock hitting the `slow-timeout`) surfaces as that one test failing.
//!
//! The bad-input cases here need no database fixture; the corrupt-copy and parked-32-bit cases
//! gate on a corpus fixture and skip without one.

use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};

use assert2::assert;
use idakit::prelude::*;
use rstest::rstest;

mod common;

/// A throwaway file (or directory, or a path that never gets created) under the temp dir,
/// deleted on drop. The kernel tests run serially (the `serial-kernel` nextest group), so a
/// fixed per-test name cannot collide.
struct Scratch(PathBuf);

impl Scratch {
    /// Write `bytes` to a fresh temp file named `name`.
    fn new(name: &str, bytes: &[u8]) -> Self {
        let path = std::env::temp_dir().join(name);
        fs::write(&path, bytes).expect("write scratch file");
        Self(path)
    }

    /// A path under the temp dir that is never created, for the "file does not exist" case.
    fn missing(name: &str) -> Self {
        Self(std::env::temp_dir().join(name))
    }

    /// The shared system temp directory itself, a directory rather than a file. `Drop` only
    /// ever attempts `remove_file`, which refuses a directory, so this never touches it.
    fn directory() -> Self {
        Self(std::env::temp_dir())
    }

    /// The first `len` bytes of `src`, a header-truncated database. Reads only the prefix,
    /// never the whole (potentially huge) source.
    fn truncated(name: &str, src: impl AsRef<Path>, len: usize) -> Self {
        let mut file = fs::File::open(src).expect("open source db");
        let mut buf = vec![0u8; len];
        let n = file.read(&mut buf).expect("read source prefix");
        buf.truncate(n);
        Self::new(name, &buf)
    }

    fn path(&self) -> String {
        self.0.to_str().expect("utf-8 temp path").to_owned()
    }
}

impl Drop for Scratch {
    fn drop(&mut self) {
        let _ = fs::remove_file(&self.0);
    }
}

/// Open `path` and assert the kernel rejected it as one of the two typed shapes `open` can fail
/// with: [`Error::Open`] (a normal rejection, whose `reason`/`path` must carry real content) or
/// [`Error::KernelExit`] (a trapped fatal exit). Anything else, including success, is a failure.
/// Runs as the kernel job body so the test stays at one indent level.
fn open_is_rejected(idb: &mut Database, path: &str) {
    let err = match idb.open(path).call() {
        Ok(()) => panic!("open of {path:?} should be rejected, but it opened"),
        Err(e) => e,
    };
    match &err {
        Error::Open {
            path: reported,
            reason,
            ..
        } => {
            assert!(
                reported == path,
                "Error::Open.path {reported:?} disagrees with the requested {path:?}"
            );
            assert!(
                !reason.is_empty(),
                "Error::Open.reason is empty for {path:?}"
            );
        }
        // A trapped fatal exit is the other legitimate containment shape (see signals/side
        // effects on the exit trap); it still carries a code, checked structurally here.
        Error::KernelExit { code, .. } => {
            assert!(*code != 0, "KernelExit.code is 0 for a rejected open");
        }
        other => panic!("open of {path:?} rejected with an unexpected error shape: {other:?}"),
    }
}

/// Drive `open_is_rejected` against `path` on the kernel thread.
fn assert_open_rejected(path: String) {
    Ida::run(move |ida| {
        ida.call(move |idb| open_is_rejected(idb, &path))
            .unwrap_or_else(|e| e.resume());
    })
    .expect("kernel init failed");
}

#[rstest]
#[case::nonexistent_path(Scratch::missing("idakit-faults-missing.i64"))]
#[case::empty_file(Scratch::new("idakit-faults-empty.i64", b""))]
#[case::garbage_bytes(Scratch::new("idakit-faults-garbage.i64", &[0xABu8; 4096]))]
#[case::directory_path(Scratch::directory())]
fn bad_input_is_rejected(#[case] scratch: Scratch) {
    assert_open_rejected(scratch.path());
}

/// A 32-bit `.idb` (IDA1) our `__EA64__` build cannot open at all: it must be refused with a
/// typed error, not crash or silently misinterpret the file as 64-bit. Gates on the manifest
/// declaring a parked (`opens = "parked"`) fixture and skips without one. The manifest's own
/// fixtures are masters (`chmod a-w`); this copies before opening rather than touching one
/// directly, matching every other corpus-driven test here.
#[test]
fn parked_32bit_database_is_rejected() {
    let Some(source) = idakit::corpus::parked_fixture() else {
        eprintln!("skipping: no parked (32-bit) fixture in the corpus manifest");
        return;
    };
    let copy = common::TestDb::copy_of(&source);
    assert_open_rejected(copy.path().to_owned());
}

/// A Java class newer than IDA's loader supports (major 69 = Java 25) is rejected through the
/// `msg()` channel, which in headless routes to a no-op sink and never reaches stderr. The
/// `KernelExit` diagnostic must still carry the loader's reason. It was `None` while only the
/// stderr channel was captured.
#[test]
#[cfg_attr(
    not(target_os = "linux"),
    ignore = "the rejection makes idalib exit(); trapping it needs the Linux-only exit trap"
)]
fn unsupported_java_class_reports_reason() {
    // Minimal class file: magic, minor=0, major=69, a one-entry constant pool, then zeroed
    // section counts, enough for IDA's Java loader to recognize the format and reject the version.
    const TOO_NEW_CLASS: &[u8] = &[
        0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x45, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    ];
    let scratch = Scratch::new("idakit-faults-toonew.class", TOO_NEW_CLASS);
    let path = scratch.path();
    Ida::run(move |ida| {
        ida.call(move |idb| {
            let err = idb
                .open(&path)
                .call()
                .expect_err("too-new class should be rejected");
            assert!(let Error::KernelExit { diagnostic, .. } = &err);
            let diag = diagnostic.as_deref().unwrap_or("");
            assert!(
                diag.contains("Java file format"),
                "KernelExit diagnostic should carry the loader reason, got {diagnostic:?}"
            );
        })
        .unwrap_or_else(|e| e.resume());
    })
    .expect("kernel init failed");
}

#[test]
#[cfg_attr(
    not(target_os = "linux"),
    ignore = "a corrupt-header database makes idalib call exit(); trapping that needs the \
              GOT-redirect exit trap, which is Linux-only (elsewhere the process just exits)"
)]
fn truncated_database_is_rejected() {
    let Some(db) = common::TestDb::source() else {
        return;
    };
    let scratch = Scratch::truncated("idakit-faults-truncated.i64", &db, 4096);
    assert_open_rejected(scratch.path());
}