droidsaw 1.0.0

DROIDSAW — unified Android reverse engineering CLI. Hermes, DEX, APK signing. JSON output, MCP server. Bytecode is not a security layer.
Documentation
//! React Native bridge resolver — pairs JS-side `(NativeModuleName,
//! NativeModuleMethodName)` identities to their Java-side
//! `(dex_idx, MethodIdx)` implementations by walking DEX annotations.
//!
//! ## Two annotations stitched together
//!
//! * `@com.facebook.react.module.annotations.ReactModule(name="X")` —
//!   class-level. Identifies the JS-visible module name for the class.
//! * `@com.facebook.react.bridge.ReactMethod` — method-level. Marks the
//!   methods exposed across the bridge.
//!
//! A method's `(NativeModuleName, NativeModuleMethodName)` is recovered
//! by walking up to its enclosing class and reading the class's
//! `@ReactModule(name=...)` element. Classes without `@ReactModule(name=X)`
//! (legacy pre-0.65 RN modules that override `getName()` at runtime)
//! land in [`BridgeResolver::by_method`] but NOT in
//! [`BridgeResolver::mappings`] — the downstream stitcher surfaces these
//! via `AmbiguousCause::LegacyNoReactModule` rather than fabricating a
//! synthetic module name for legacy React Native modules.

use std::collections::BTreeMap;

use droidsaw_common::cross_layer_taint::{
    JsBridgeKey, NativeModuleMethodName, NativeModuleName,
};
use droidsaw_dex::annotation::EncodedValue;
use droidsaw_dex::ids::{MethodIdx, TypeIdx};

use crate::context::CrossLayerContext;

/// Annotation type descriptors for the class-level `@ReactModule`.
/// RN ships two equivalent forms in production:
///   * `com.facebook.react.module.annotations.ReactModule` — current
///     canonical (RN ≥0.20 in this exact package path);
///   * `com.facebook.react.bridge.ReactModule` — older form still
///     embedded in legacy / community RN module SDKs.
/// Both carry the same `name="X"` element shape; the resolver accepts
/// either descriptor on a class-by-class basis.
const REACT_MODULE_ANNS: &[&str] = &[
    "Lcom/facebook/react/module/annotations/ReactModule;",
    "Lcom/facebook/react/bridge/ReactModule;",
];
const REACT_METHOD_ANN: &str = "Lcom/facebook/react/bridge/ReactMethod;";
/// Element name within `@ReactModule(name="X")` — the only element the
/// resolver reads (other elements like `canOverrideExistingModule` are
/// ignored).
const REACT_MODULE_NAME_ELEMENT: &str = "name";

pub struct BridgeResolver {
    /// Per-`(module, method)` keyed bridge targets — the primary precision
    /// channel. Populated only for `@ReactMethod`-annotated methods whose
    /// enclosing class carries `@ReactModule(name=X)`. Downstream analysis
    /// will join per-finding `arg_positions` against these targets to revive
    /// per-position DEX seeding.
    pub mappings: BTreeMap<JsBridgeKey, Vec<(usize, MethodIdx)>>,

    /// Legacy by-method-name fallback. Mirrors the pre-precision shape.
    /// Used by downstream analysis for `AmbiguousCause::ResolverZeroMatch`
    /// (no `mappings` entry but matches exist in `by_method`) and
    /// `AmbiguousCause::ResolverMultiMatch` (multiple candidates) diagnostic
    /// detail; also reads by `bridge_targets` construction (every
    /// `@ReactMethod` gets considered, not just those whose class has
    /// `@ReactModule`).
    pub by_method: BTreeMap<String, Vec<(usize, MethodIdx)>>,
}

impl BridgeResolver {
    pub fn resolve(ctx: &CrossLayerContext) -> Self {
        let mut mappings: BTreeMap<JsBridgeKey, Vec<(usize, MethodIdx)>> = BTreeMap::new();
        let mut by_method: BTreeMap<String, Vec<(usize, MethodIdx)>> = BTreeMap::new();

        // Bridge resolution needs raw DEX bytes from the APK. Raw HBC
        // inputs have no APK and no DEX, so early-return an empty resolver.
        let Some(apk) = ctx.apk.as_ref() else {
            return Self { mappings, by_method };
        };

        // CrossLayerContext invariant: ctx.dex is built from apk.dex, so
        // ctx.dex is a subset of apk.dex by length. zip below stops at min;
        // a future regression that grows ctx.dex past apk.dex would silently
        // truncate. debug_assert surfaces such regressions on test/CI builds.
        debug_assert!(
            ctx.dex.len() <= apk.dex.len(),
            "internal: ctx.dex (len={}) must not exceed apk.dex (len={}) — \
             CrossLayerContext invariant violation",
            ctx.dex.len(),
            apk.dex.len(),
        );

        for ((i, dex), apk_dex) in ctx.dex.iter().enumerate().zip(apk.dex.iter()) {
            let data = &apk_dex.data;

            // Step 1: gate-then-scan. `find_annotated_methods` returns
            // Err(DetectorIndeterminate) when the annotation subtree had
            // any parse failure (`subsection_clean(ANNOTATION_SUBSECTION_KINDS)`
            // check inside the dex crate). Running it FIRST and falling
            // through on Err propagates the same gate to
            // `scan_react_module_annotations` below, which reads the same
            // `annotations` / `annotation_sets` / `annotation_items`
            // maps directly and has no other way to refuse a partial
            // annotation tree. The Finding for the tolerantly-recorded
            // ParseFailure is surfaced via
            // diag::collect_detector_indeterminate_findings.
            let Ok(annotated) = dex.find_annotated_methods(data, REACT_METHOD_ANN) else {
                continue;
            };

            // Step 2: walk class_defs, collect @ReactModule(name=X) per
            // class. class_module_name maps a class's TypeIdx to its
            // recovered NativeModuleName. Classes without the annotation
            // (or with empty name / non-String value) are absent — the
            // bridge join below skips their @ReactMethod methods.
            let class_module_name = scan_react_module_annotations(dex);
            for m_idx in annotated {
                #[allow(
                    clippy::as_conversions,
                    reason = "PROOF: u32 → usize widening, lossless on 64-bit; .get() handles OOB."
                )]
                let Some(m_id_item) = dex.methods.get(m_idx.0 as usize) else {
                    continue;
                };
                let Ok(method_name) = dex.get_string(m_id_item.name_idx) else {
                    continue;
                };

                // Legacy fallback: every @ReactMethod, by method name.
                by_method
                    .entry(method_name.to_string())
                    .or_default()
                    .push((i, m_idx));

                // Precision channel: only when the enclosing class carries
                // @ReactModule(name=X). By design, no silent fallback name;
                // the legacy path stays in `by_method` only and downstream
                // analysis emits AmbiguousCause::LegacyNoReactModule.
                let Some(module) = class_module_name.get(&m_id_item.class_idx) else {
                    continue;
                };
                let Some(method) = NativeModuleMethodName::try_new(method_name.to_string()) else {
                    continue;
                };
                let key = JsBridgeKey::new(module.clone(), method);
                mappings.entry(key).or_default().push((i, m_idx));
            }
        }

        Self { mappings, by_method }
    }
}

/// Walk every class_def in `dex` and recover the
/// `@ReactModule(name=X)` mapping `TypeIdx → NativeModuleName` for
/// classes that carry the annotation with a non-empty String `name`
/// element.
///
/// Classes whose annotation parses (visibility check + type match)
/// but whose `name` element is missing, non-String, or empty are
/// silently absent from the returned map — downstream analysis's
/// `AmbiguousCause::LegacyNoReactModule` covers the "no annotation"
/// case; a malformed annotation is functionally identical to "no
/// annotation" from the resolver's perspective.
fn scan_react_module_annotations(
    dex: &droidsaw_dex::DexFile,
) -> BTreeMap<TypeIdx, NativeModuleName> {
    let mut out: BTreeMap<TypeIdx, NativeModuleName> = BTreeMap::new();
    for class_def in &dex.class_defs {
        if class_def.annotations_off == 0 {
            continue;
        }
        let Some(dir) = dex.annotations.get(&class_def.annotations_off) else {
            continue;
        };
        if dir.class_annotations_off == 0 {
            continue;
        }
        let Some(set) = dex.annotation_sets.get(&dir.class_annotations_off) else {
            continue;
        };
        for item_off in set {
            let Some(item) = dex.annotation_items.get(item_off) else {
                continue;
            };
            let Ok(desc) = dex.get_type_descriptor(item.annotation.type_idx) else {
                continue;
            };
            if !REACT_MODULE_ANNS.iter().any(|d| *d == desc) {
                continue;
            }
            // Find the `name` element. The element key is a StringIdx
            // pointing at the element-name string in the pool; the
            // value is the EncodedValue (String for @ReactModule.name).
            for (k, v) in &item.annotation.elements {
                let Ok(elem_name) = dex.get_string(*k) else {
                    continue;
                };
                if elem_name != REACT_MODULE_NAME_ELEMENT {
                    continue;
                }
                let EncodedValue::String(name_idx) = v else {
                    continue;
                };
                let Ok(name_str) = dex.get_string(*name_idx) else {
                    continue;
                };
                let Some(nm) = NativeModuleName::try_new(name_str.to_string()) else {
                    continue;
                };
                out.insert(class_def.class_idx, nm);
                break;
            }
        }
    }
    out
}