openlatch-client 0.5.2

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! `backtrace::Backtrace` → wire frames.

use std::path::Path;

use super::images::{find_module, LoadedModule};
use super::payload::Frame;

/// Convert a captured backtrace into wire frames.
///
/// ORDER: emitted in the order `backtrace::Backtrace` yields, which is NEWEST-FIRST — the
/// panic site leads and the thread entry point is last. There is no reversal, and that is
/// deliberate rather than settled: the wire contract transcribed from the server says
/// nothing about stack order, so there is nothing to conform to yet. Do not "fix" it in
/// either direction without checking a real issue in the PostHog UI first; a reversal
/// made on inference would be as unsourced as leaving it.
///
/// `in_app`: TRUE only when the resolved filename sits under this crate's source root,
/// FALSE otherwise — including std, the runtime and every dependency. The server defaults
/// an ABSENT `in_app` to true, so every frame sets it explicitly and none is skipped by
/// serde.
pub fn build_frames(bt: &backtrace::Backtrace, modules: &[LoadedModule]) -> Vec<Frame> {
    let mut frames = Vec::new();
    for bt_frame in bt.frames() {
        let ip = bt_frame.ip() as usize as u64;
        let module = find_module(modules, ip);

        // `Backtrace::new()` ALREADY RESOLVED these — read the symbols it captured.
        //
        // Do NOT reach for `backtrace::resolve_frame(bt_frame, ...)`: it takes a
        // `&backtrace::Frame` (the raw trace frame), while `Backtrace::frames()` yields
        // `&[BacktraceFrame]`, whose inner `frame` field is private with no accessor.
        // There is no public path between the two types, so that call is a type error
        // with no workaround at the call site — and it would be redundant work besides.
        //
        // One physical frame yields SEVERAL symbols when inlining collapsed them. We emit
        // one wire frame per symbol and do NOT set `inline`: the inline-group protocol
        // requires the physical frame first carrying the same instruction_addr, and
        // getting it half-right makes the server re-expand the chain once per member.
        // Flat frames with client_resolved=true are the honest shape.
        let symbols = bt_frame.symbols();
        for symbol in symbols {
            frames.push(frame_from_symbol(ip, module, symbol));
        }
        if symbols.is_empty() {
            // Stripped or unresolvable in-process: address only. This is the normal case
            // for a release build, and the case symbolication exists to fix.
            let mut frame = Frame::address_only(
                Some(format!("0x{ip:x}")),
                module.map(|m| m.image.image_addr.clone()),
            );
            frame.module = module_name(module);
            frames.push(frame);
        }
    }
    frames
}

/// The containing image's file name, for display when nothing else resolves.
///
/// The base name only: the full path would put a user's home directory on the wire for
/// no display benefit.
fn module_name(module: Option<&LoadedModule>) -> Option<String> {
    module
        .and_then(|m| m.image.code_file.as_deref())
        .map(Path::new)
        .and_then(|p| p.file_name())
        .map(|n| n.to_string_lossy().into_owned())
}

/// True when `path` is inside this crate's source tree.
///
/// `CARGO_MANIFEST_DIR` is a BUILD-time path and is meaningless on a user's machine, so
/// the test is on the path SHAPE that rustc bakes into debug info: a relative `src/...`
/// path, or an absolute path with a `src` segment. Anything under `/rustc/`, the standard
/// library, `.cargo/registry` or `.cargo/git` is explicitly not ours, and those
/// exclusions are checked FIRST — `/rustc/<hash>/library/core/src/panicking.rs` carries a
/// `src` segment too.
fn is_in_app(filename: Option<&Path>) -> bool {
    let Some(path) = filename else {
        return false;
    };
    let normalized = path.to_string_lossy().replace('\\', "/");

    const NOT_OURS: &[&str] = &[
        "/rustc/",
        "cargo/registry/",
        "cargo/git/",
        "/library/std/",
        "/library/core/",
        "/library/alloc/",
    ];
    if NOT_OURS.iter().any(|marker| normalized.contains(marker)) {
        return false;
    }

    normalized.starts_with("src/") || normalized.contains("/src/")
}

/// Build one wire frame from a resolved symbol.
///
/// `symbol` is a `&backtrace::BacktraceSymbol`, NOT a `&backtrace::Symbol` — the two
/// carry the same accessors but only the former is reachable from a captured
/// `Backtrace`.
fn frame_from_symbol(
    ip: u64,
    module: Option<&LoadedModule>,
    symbol: &backtrace::BacktraceSymbol,
) -> Frame {
    let filename = symbol.filename();
    Frame {
        platform: "native",
        instruction_addr: Some(format!("0x{ip:x}")),
        image_addr: module.map(|m| m.image.image_addr.clone()),
        lang: "rust",
        module: module_name(module),
        function: symbol
            .name()
            .map(|n| n.to_string())
            .filter(|n| !n.is_empty()),
        filename: filename.map(|p| p.to_string_lossy().into_owned()),
        lineno: symbol.lineno(),
        colno: symbol.colno(),
        client_resolved: true,
        in_app: is_in_app(filename),
        synthetic: false,
    }
}

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

    fn in_app(p: &str) -> bool {
        is_in_app(Some(&PathBuf::from(p)))
    }

    #[test]
    fn our_own_source_paths_are_in_app() {
        assert!(in_app("src/core/telemetry/crash/mod.rs"));
        assert!(in_app("src\\core\\telemetry\\crash\\mod.rs"));
        assert!(in_app("/home/dev/openlatch-client/src/daemon/mod.rs"));
        assert!(in_app("D:/GITOSIS/openlatch-client/src/daemon/mod.rs"));
    }

    #[test]
    fn the_standard_library_is_never_in_app() {
        assert!(!in_app(
            "/rustc/9b00956e56009bab2aa15d7bff10916599e3d6d6/library/core/src/panicking.rs"
        ));
        assert!(!in_app("/library/std/src/thread/mod.rs"));
    }

    #[test]
    fn dependencies_are_never_in_app() {
        assert!(!in_app(
            "/home/dev/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.0/src/lib.rs"
        ));
        assert!(!in_app(
            "/home/dev/.cargo/git/checkouts/axum-abc/src/lib.rs"
        ));
        assert!(!in_app(
            "C:\\Users\\dev\\.cargo\\registry\\src\\index\\serde-1\\src\\lib.rs"
        ));
    }

    #[test]
    fn a_frame_with_no_filename_is_never_in_app() {
        assert!(!is_in_app(None));
    }

    /// The regression this pins: an unresolved frame must still carry `in_app` false
    /// rather than leaving the server to default it to true.
    #[test]
    fn an_address_only_frame_is_not_in_app_and_not_client_resolved() {
        let frame = Frame::address_only(Some("0x1000".into()), None);
        assert!(!frame.in_app);
        assert!(!frame.client_resolved);
    }

    /// A real capture of this process. Whatever the platform resolves, every frame must
    /// serialize `in_app` — that is the field whose absence is silently wrong.
    #[test]
    fn every_built_frame_serializes_in_app() {
        let bt = backtrace::Backtrace::new();
        let modules = super::super::images::collect_loaded_modules();
        let frames = build_frames(&bt, &modules);
        assert!(!frames.is_empty(), "a live backtrace has frames");
        for frame in &frames {
            let v = serde_json::to_value(frame).expect("frame serializes");
            let obj = v.as_object().expect("frame is an object");
            assert!(obj.contains_key("in_app"), "in_app missing: {v}");
            assert_eq!(obj["platform"], "native");
            assert_eq!(obj["lang"], "rust");
        }
    }

    #[test]
    fn module_name_is_the_base_name_not_the_path() {
        let module = LoadedModule {
            base: 0x1000,
            end: 0x2000,
            image: super::super::payload::DebugImage {
                debug_id: "aaa".into(),
                image_addr: "0x1000".into(),
                image_size: Some(0x1000),
                image_vmaddr: Some("0x0".into()),
                code_file: Some("/home/alice/.openlatch/bin/openlatch".into()),
                image_type: Some("elf".into()),
                arch: Some("x86_64".into()),
            },
            reportable: true,
        };
        assert_eq!(module_name(Some(&module)).as_deref(), Some("openlatch"));
        assert_eq!(module_name(None), None);
    }
}