openlatch-client 0.5.4

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Module enumeration and the `debug_id` derivation.
//!
//! This is the file most likely to be silently wrong — a `debug_id` with the wrong case
//! or the wrong bytes produces a well-formed event that never symbolicates and never
//! errors — so it is also the file with the most tests.

use super::payload::{DebugImage, Frame};

/// A module mapped into this process, used both to attach `image_addr` to frames and to
/// build the event-level `$debug_images` list.
#[derive(Debug, Clone)]
pub struct LoadedModule {
    pub base: u64,
    pub end: u64,
    pub image: DebugImage,
    /// False when `debug_id` came back empty: the module is still needed for address
    /// matching, but must never appear in `$debug_images` — an image with no debug id
    /// can match no symbol set.
    pub reportable: bool,
}

/// Derive a debug id from a GNU build id.
///
/// Must match `symbolic`'s `ElfObject::compute_debug_id`, which the PostHog server and
/// `posthog-cli` both use at UPLOAD time. symbolic byte-swaps the first three GUID fields
/// only for LITTLE-ENDIAN ELF objects. We are enumerating our own process, so the
/// object's endianness is this target's endianness — hence the cfg gate rather than an
/// unconditional swap. A big-endian target (s390x, powerpc64) that swapped would produce
/// an id matching nothing, silently.
///
/// Output case: LOWERCASE (`Uuid::to_string()`'s default). posthog-cli's upload path
/// refuses to normalise case: "the SDK matches chunk_ids case-sensitively, per format,
/// and lowercase ELF ids never collide with uppercase Mach-O ones."
fn debug_id_from_gnu_build_id(build_id: &[u8]) -> Option<String> {
    if build_id.is_empty() {
        return None;
    }
    let mut data = [0u8; 16];
    let len = build_id.len().min(16);
    data[..len].copy_from_slice(&build_id[..len]);
    if cfg!(target_endian = "little") {
        data[0..4].reverse();
        data[4..6].reverse();
        data[6..8].reverse();
    }
    Some(uuid::Uuid::from_bytes(data).to_string())
}

/// Render 16 bytes laid out as a little-endian GUID as a canonical UUID string.
///
/// PDB signatures are always stored little-endian on disk, so this swap is
/// UNCONDITIONAL — unlike the ELF path above.
fn guid_le_to_uuid(mut data: [u8; 16]) -> String {
    data[0..4].reverse();
    data[4..6].reverse();
    data[6..8].reverse();
    uuid::Uuid::from_bytes(data).to_string()
}

/// Map a `findshlibs` library identifier onto the debug id the symbol set is keyed by.
///
/// The case rule per format is load-bearing; see the module doc.
fn debug_id_for(id: &findshlibs::SharedLibraryId) -> Option<String> {
    use findshlibs::SharedLibraryId;
    match id {
        SharedLibraryId::GnuBuildId(bytes) => debug_id_from_gnu_build_id(bytes),
        // UPPERCASE, to match the chunk ids posthog-cli stores for a dSYM — it takes them
        // verbatim from `dwarfdump` output.
        SharedLibraryId::Uuid(bytes) => {
            Some(uuid::Uuid::from_bytes(*bytes).to_string().to_uppercase())
        }
        SharedLibraryId::PdbSignature(guid, age) => {
            let uuid = guid_le_to_uuid(*guid);
            Some(if *age > 0 {
                format!("{uuid}-{age:x}")
            } else {
                uuid
            })
        }
        // A PE timestamp/size signature carries no debug id symbols can be matched to.
        _ => None,
    }
}

/// The debug-file format this target produces. A `cfg!` match, not a runtime probe:
/// a binary cannot change the object format it was linked as.
fn native_image_type() -> &'static str {
    if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
        "macho"
    } else if cfg!(target_os = "windows") {
        "pe"
    } else {
        "elf"
    }
}

/// Rust's `ARCH` names differ from the symbolication vocabulary in exactly one place that
/// matters to us: `aarch64` is written `arm64` in a debug image.
fn normalize_arch(arch: &str) -> Option<String> {
    let normalized = match arch {
        "aarch64" => "arm64",
        other => other,
    };
    (!normalized.is_empty()).then(|| normalized.to_string())
}

/// Enumerate modules mapped into this process, sorted by load address.
///
/// `image_addr` is the ACTUAL load address (preferred base + ASLR slide) and is what the
/// server matches frames against; `image_vmaddr` is the stated one and is informational.
/// Confusing the two produces images that never match a frame.
pub fn collect_loaded_modules() -> Vec<LoadedModule> {
    use findshlibs::{IterationControl, SharedLibrary, TargetSharedLibrary};

    let mut modules: Vec<LoadedModule> = Vec::new();

    TargetSharedLibrary::each(|shlib| {
        let base = shlib.actual_load_addr().0 as u64;
        let size = shlib.len() as u64;

        // The main executable's name is empty on Linux; fall back to the exe path so the
        // image the panic actually came from is not the one with no `code_file`.
        let name = shlib.name().to_string_lossy().into_owned();
        let code_file = if name.is_empty() {
            std::env::current_exe()
                .ok()
                .map(|p| p.to_string_lossy().into_owned())
        } else {
            Some(name)
        };

        // `debug_id()` is the identifier uploaded symbols are keyed by (PDB GUID+age on
        // Windows, the same as `id()` elsewhere). `id()` is the full code identifier and
        // is NOT what a symbol set is looked up with.
        let debug_id = shlib
            .debug_id()
            .as_ref()
            .and_then(debug_id_for)
            .unwrap_or_default();
        let reportable = !debug_id.is_empty();

        modules.push(LoadedModule {
            base,
            end: base.saturating_add(size),
            image: DebugImage {
                debug_id,
                image_addr: format!("0x{base:x}"),
                image_size: Some(size),
                image_vmaddr: Some(format!("0x{:x}", shlib.stated_load_addr().0 as u64)),
                code_file,
                image_type: Some(native_image_type().to_string()),
                arch: normalize_arch(std::env::consts::ARCH),
            },
            reportable,
        });

        IterationControl::Continue
    });

    modules.sort_by_key(|m| m.base);
    modules
}

/// Binary search by load address.
///
/// `partition_point` then a range check, because a module list is sorted and
/// contiguous-ish but not gapless: an address in a hole between two modules belongs to
/// neither.
pub fn find_module(modules: &[LoadedModule], addr: u64) -> Option<&LoadedModule> {
    let idx = modules.partition_point(|m| m.base <= addr);
    let module = modules[..idx].last()?;
    (addr < module.end).then_some(module)
}

/// Trim to the images some captured frame actually points into, preserving load order.
///
/// Two exclusions, both deliberate: an image no frame references is noise, and an image
/// whose `debug_id` came back empty can never match a symbol set even when referenced.
pub fn referenced_images(modules: &[LoadedModule], frames: &[Frame]) -> Vec<DebugImage> {
    modules
        .iter()
        .filter(|m| m.reportable)
        .filter(|m| {
            frames
                .iter()
                .any(|f| f.image_addr.as_deref() == Some(m.image.image_addr.as_str()))
        })
        .map(|m| m.image.clone())
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Both halves of the ELF rule in one fixture: the swap happens on little-endian
    /// targets and must NOT happen on big-endian ones.
    #[test]
    fn elf_build_id_derives_the_swapped_lowercase_uuid() {
        let build_id: Vec<u8> = (1u8..=20).collect();
        let got = debug_id_from_gnu_build_id(&build_id).expect("20-byte build id derives");
        let want = if cfg!(target_endian = "little") {
            "04030201-0605-0807-090a-0b0c0d0e0f10"
        } else {
            "01020304-0506-0708-090a-0b0c0d0e0f10"
        };
        assert_eq!(got, want);
        assert_eq!(got, got.to_lowercase(), "ELF debug ids are lowercase");
    }

    #[test]
    fn a_short_elf_build_id_zero_pads_before_the_swap() {
        let build_id: Vec<u8> = (1u8..=8).collect();
        let got = debug_id_from_gnu_build_id(&build_id).expect("8-byte build id derives");
        let want = if cfg!(target_endian = "little") {
            "04030201-0605-0807-0000-000000000000"
        } else {
            "01020304-0506-0708-0000-000000000000"
        };
        assert_eq!(got, want);
    }

    #[test]
    fn an_empty_elf_build_id_has_no_debug_id() {
        assert_eq!(debug_id_from_gnu_build_id(&[]), None);
    }

    /// A test that checked only the hex digits would pass on a broken implementation —
    /// the case IS the assertion.
    #[test]
    fn macho_uuid_is_uppercase() {
        let bytes: [u8; 16] = [
            0x67, 0xe9, 0x24, 0x7c, 0x81, 0x4e, 0x39, 0x2b, 0xa0, 0x27, 0xdb, 0xde, 0x67, 0x48,
            0xfc, 0xbf,
        ];
        let got = debug_id_for(&findshlibs::SharedLibraryId::Uuid(bytes)).expect("derives");
        assert_eq!(got, "67E9247C-814E-392B-A027-DBDE6748FCBF");
        assert_ne!(
            got,
            got.to_lowercase(),
            "a lowercase Mach-O id never symbolicates"
        );
    }

    #[test]
    fn pdb_signature_with_an_age_appends_it_in_hex() {
        let guid: [u8; 16] = [
            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
            0x0f, 0x10,
        ];
        let got = debug_id_for(&findshlibs::SharedLibraryId::PdbSignature(guid, 27)).expect("id");
        // The GUID swap here is UNCONDITIONAL, unlike the ELF path.
        assert_eq!(got, "04030201-0605-0807-090a-0b0c0d0e0f10-1b");
    }

    #[test]
    fn pdb_signature_with_age_zero_has_no_suffix() {
        let guid: [u8; 16] = [
            0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
            0x0f, 0x10,
        ];
        let got = debug_id_for(&findshlibs::SharedLibraryId::PdbSignature(guid, 0)).expect("id");
        assert_eq!(got, "04030201-0605-0807-090a-0b0c0d0e0f10");
    }

    #[test]
    fn a_pe_timestamp_signature_yields_no_debug_id() {
        assert_eq!(
            debug_id_for(&findshlibs::SharedLibraryId::PeSignature(0xdead, 0x1000)),
            None
        );
    }

    fn module(base: u64, end: u64, debug_id: &str) -> LoadedModule {
        LoadedModule {
            base,
            end,
            image: DebugImage {
                debug_id: debug_id.to_string(),
                image_addr: format!("0x{base:x}"),
                image_size: Some(end - base),
                image_vmaddr: Some("0x0".into()),
                code_file: Some(format!("mod{base}")),
                image_type: Some("elf".into()),
                arch: Some("x86_64".into()),
            },
            reportable: !debug_id.is_empty(),
        }
    }

    #[test]
    fn find_module_answers_below_inside_in_a_gap_and_past_the_end() {
        let modules = vec![module(0x1000, 0x2000, "a"), module(0x3000, 0x4000, "b")];
        assert!(find_module(&modules, 0x0fff).is_none(), "below the first");
        assert_eq!(
            find_module(&modules, 0x1500).map(|m| m.base),
            Some(0x1000),
            "inside the first"
        );
        assert!(find_module(&modules, 0x2500).is_none(), "in the gap");
        assert_eq!(
            find_module(&modules, 0x3000).map(|m| m.base),
            Some(0x3000),
            "the base address is inside"
        );
        assert!(find_module(&modules, 0x4000).is_none(), "end is exclusive");
        assert!(find_module(&modules, 0x9999).is_none(), "past the end");
        assert!(find_module(&[], 0x1000).is_none(), "empty list");
    }

    #[test]
    fn referenced_images_drops_unreferenced_and_undebuggable_images() {
        let modules = vec![
            module(0x1000, 0x2000, "aaa"),
            module(0x3000, 0x4000, "bbb"),
            // Referenced below, but with no debug id: excluded even so.
            module(0x5000, 0x6000, ""),
        ];
        let frames = vec![
            Frame::address_only(Some("0x1100".into()), Some("0x1000".into())),
            Frame::address_only(Some("0x5100".into()), Some("0x5000".into())),
        ];
        let images = referenced_images(&modules, &frames);
        assert_eq!(images.len(), 1, "got: {images:?}");
        assert_eq!(images[0].debug_id, "aaa");
    }

    #[test]
    fn referenced_images_preserves_load_order() {
        let modules = vec![module(0x1000, 0x2000, "aaa"), module(0x3000, 0x4000, "bbb")];
        let frames = vec![
            Frame::address_only(Some("0x3100".into()), Some("0x3000".into())),
            Frame::address_only(Some("0x1100".into()), Some("0x1000".into())),
        ];
        let ids: Vec<_> = referenced_images(&modules, &frames)
            .into_iter()
            .map(|i| i.debug_id)
            .collect();
        assert_eq!(ids, vec!["aaa", "bbb"]);
    }

    #[test]
    fn arch_is_normalized_to_the_symbolication_vocabulary() {
        assert_eq!(normalize_arch("aarch64").as_deref(), Some("arm64"));
        assert_eq!(normalize_arch("x86_64").as_deref(), Some("x86_64"));
        assert_eq!(normalize_arch("").as_deref(), None);
    }

    /// Enumeration must work on every target we ship, and must never produce an image
    /// that claims a debug id it does not have.
    #[test]
    fn collect_loaded_modules_yields_a_sorted_self_consistent_list() {
        let modules = collect_loaded_modules();
        assert!(
            modules.windows(2).all(|w| w[0].base <= w[1].base),
            "modules must be sorted by load address"
        );
        for m in &modules {
            assert_eq!(
                m.reportable,
                !m.image.debug_id.is_empty(),
                "reportable must track debug_id presence"
            );
            assert_eq!(m.image.image_addr, format!("0x{:x}", m.base));
            assert!(m.end >= m.base, "a module cannot end before it starts");
        }
    }
}