Skip to main content

threatflux_binary_analysis/analysis/
call_graph.rs

1//! Call graph analysis for binary programs
2//!
3//! This module provides functionality to analyze function call relationships in binary programs,
4//! including call graph construction, cycle detection, and visualization export.
5
6use crate::{
7    BinaryError, BinaryFile, Result,
8    disasm::{Disassembler, DisassemblyConfig, section_file_data},
9    types::{
10        CallContext, CallGraph, CallGraphConfig, CallGraphEdge, CallGraphNode, CallGraphStatistics,
11        CallSite, CallType, Function, Instruction, NodeType,
12    },
13};
14use petgraph::{algo::kosaraju_scc, graphmap::DiGraphMap};
15use std::collections::{HashMap, HashSet, VecDeque};
16
17type AddressToNode = HashMap<u64, usize>;
18type CallAdjacency = HashMap<u64, Vec<u64>>;
19
20/// Call graph analyzer
21#[derive(Clone)]
22pub struct CallGraphAnalyzer {
23    /// Analysis configuration
24    config: CallGraphConfig,
25}
26
27impl CallGraphAnalyzer {
28    /// Create a new call graph analyzer
29    pub fn new(config: CallGraphConfig) -> Self {
30        Self { config }
31    }
32
33    /// Create analyzer with default configuration
34    pub fn new_default() -> Self {
35        Self {
36            config: CallGraphConfig::default(),
37        }
38    }
39
40    /// Analyze binary to construct call graph
41    pub fn analyze_binary(&self, binary: &BinaryFile) -> Result<CallGraph> {
42        // Extract functions from binary
43        let functions = self.extract_functions(binary)?;
44
45        // Build call graph nodes
46        let mut nodes = Vec::new();
47        let mut address_to_node: HashMap<u64, usize> = HashMap::new();
48        nodes
49            .try_reserve(functions.len())
50            .map_err(|error| Self::allocation_error("call-graph nodes", error))?;
51        address_to_node
52            .try_reserve(functions.len())
53            .map_err(|error| Self::allocation_error("call-graph address index", error))?;
54
55        for (i, function) in functions.iter().enumerate() {
56            let node = CallGraphNode {
57                function_address: function.start_address,
58                function_name: function.name.clone(),
59                node_type: self.classify_node_type(function, binary),
60                complexity: 0,       // Will be calculated later
61                in_degree: 0,        // Will be calculated later
62                out_degree: 0,       // Will be calculated later
63                is_recursive: false, // Will be detected later
64                call_depth: None,    // Will be calculated later
65            };
66            nodes.push(node);
67            address_to_node.insert(function.start_address, i);
68        }
69
70        // Extract function calls and build edges
71        let mut edges = self.extract_function_calls(binary, &functions, &address_to_node)?;
72        if !self.config.include_library_calls {
73            let mut library_addresses = HashSet::new();
74            library_addresses
75                .try_reserve(nodes.len())
76                .map_err(|error| Self::allocation_error("library address set", error))?;
77            library_addresses.extend(
78                nodes
79                    .iter()
80                    .filter(|node| matches!(node.node_type, NodeType::Library))
81                    .map(|node| node.function_address),
82            );
83            edges.retain(|edge| {
84                !library_addresses.contains(&edge.caller)
85                    && !edge
86                        .callee
87                        .is_some_and(|callee| library_addresses.contains(&callee))
88            });
89        }
90
91        // Update node degrees
92        self.update_node_degrees(&mut nodes, &edges);
93
94        // Detect recursion
95        self.detect_recursion(&mut nodes, &edges);
96
97        // Find entry points
98        let entry_points = self.find_entry_points(binary, &functions);
99
100        // Calculate call depths
101        let mut call_graph = CallGraph {
102            nodes,
103            edges,
104            entry_points: entry_points.clone(),
105            unreachable_functions: Vec::new(),
106            statistics: CallGraphStatistics::default(),
107        };
108
109        self.compute_call_depths(&mut call_graph)?;
110
111        // Find unreachable functions
112        call_graph.unreachable_functions = self.find_unreachable_functions(&call_graph)?;
113
114        // Compute statistics
115        call_graph.statistics = self.compute_statistics(&call_graph);
116
117        Ok(call_graph)
118    }
119
120    /// Extract functions from binary symbols and analysis
121    fn extract_functions(&self, binary: &BinaryFile) -> Result<Vec<Function>> {
122        let mut functions = Vec::new();
123        let mut seen_addresses = HashSet::new();
124        let reservation = binary
125            .symbols()
126            .len()
127            .min(self.config.max_functions.saturating_add(1));
128        functions
129            .try_reserve(reservation)
130            .map_err(|error| Self::allocation_error("discovered functions", error))?;
131        seen_addresses
132            .try_reserve(reservation)
133            .map_err(|error| Self::allocation_error("function address set", error))?;
134
135        // Extract from symbols
136        for symbol in binary.symbols() {
137            if matches!(symbol.symbol_type, crate::types::SymbolType::Function)
138                && symbol.size > 0
139                && !seen_addresses.contains(&symbol.address)
140            {
141                self.ensure_function_limit(functions.len().saturating_add(1))?;
142                let function = Function {
143                    name: symbol.name.clone(),
144                    start_address: symbol.address,
145                    end_address: symbol.address.checked_add(symbol.size).ok_or_else(|| {
146                        BinaryError::invalid_data(format!(
147                            "Function '{}' address range overflows",
148                            symbol.name
149                        ))
150                    })?,
151                    size: symbol.size,
152                    function_type: crate::types::FunctionType::Normal,
153                    calling_convention: None,
154                    parameters: Vec::new(),
155                    return_type: None,
156                };
157                functions.push(function);
158                seen_addresses.insert(symbol.address);
159            }
160        }
161
162        // Add entry point if not already present
163        if let Some(entry_point) = binary
164            .entry_point()
165            .filter(|entry_point| !seen_addresses.contains(entry_point))
166        {
167            self.ensure_function_limit(functions.len().saturating_add(1))?;
168            let function = Function {
169                name: "_start".to_string(),
170                start_address: entry_point,
171                end_address: entry_point.checked_add(1000).ok_or_else(|| {
172                    BinaryError::invalid_data("Entry-point address range overflows")
173                })?, // Estimate when symbols are unavailable
174                size: 1000,
175                function_type: crate::types::FunctionType::Entrypoint,
176                calling_convention: None,
177                parameters: Vec::new(),
178                return_type: None,
179            };
180            functions.push(function);
181        }
182
183        functions.sort_by(|left, right| {
184            left.start_address
185                .cmp(&right.start_address)
186                .then_with(|| left.name.cmp(&right.name))
187        });
188        Ok(functions)
189    }
190
191    /// Classify node type based on function characteristics
192    fn classify_node_type(&self, function: &Function, binary: &BinaryFile) -> NodeType {
193        // Check if it's an entry point
194        if matches!(
195            function.function_type,
196            crate::types::FunctionType::Entrypoint
197        ) || matches!(function.function_type, crate::types::FunctionType::Main)
198            || function.name == "_start"
199            || function.name == "main"
200            || function.name == "DllMain"
201        {
202            return NodeType::EntryPoint;
203        }
204
205        // Check if it's an imported function
206        for import in binary.imports() {
207            if import.address == Some(function.start_address) {
208                return NodeType::External;
209            }
210        }
211
212        // Check if it's a library function (heuristic based on name)
213        if self.is_library_function(&function.name) {
214            return NodeType::Library;
215        }
216
217        // Default to internal function
218        NodeType::Internal
219    }
220
221    /// Check if a function name indicates a library function
222    fn is_library_function(&self, name: &str) -> bool {
223        // Common library function prefixes (be more specific to avoid false positives)
224        const LIBRARY_PREFIXES: &[&str] =
225            &["libc_", "libm_", "__", "_GLOBAL_", "std::", "_ZN", "_Z"];
226
227        // Common library function exact names or patterns
228        const LIBRARY_NAMES: &[&str] = &[
229            "printf", "scanf", "malloc", "free", "strlen", "strcpy", "strcat", "memcpy", "memset",
230            "fopen", "fclose", "fread", "fwrite", "msvcrt", "kernel32", "ntdll",
231        ];
232
233        // Check prefixes
234        for prefix in LIBRARY_PREFIXES {
235            if name.starts_with(prefix) {
236                return true;
237            }
238        }
239
240        // Check exact function names. Substring matching (for example, "free")
241        // creates severe false positives for user-defined symbols.
242        for lib_name in LIBRARY_NAMES {
243            if name == *lib_name {
244                return true;
245            }
246        }
247
248        false
249    }
250
251    /// Extract function calls from binary analysis
252    fn extract_function_calls(
253        &self,
254        binary: &BinaryFile,
255        functions: &[Function],
256        address_to_node: &HashMap<u64, usize>,
257    ) -> Result<Vec<CallGraphEdge>> {
258        if functions.is_empty() {
259            return Ok(Vec::new());
260        }
261
262        let mut edges = Vec::new();
263        edges
264            .try_reserve(functions.len().min(self.config.max_total_instructions))
265            .map_err(|error| Self::allocation_error("call-graph edges", error))?;
266        let mut successful_functions = 0_usize;
267        let mut total_instructions = 0_usize;
268        let mut last_error = None;
269
270        for function in functions {
271            let remaining_instructions = self
272                .config
273                .max_total_instructions
274                .saturating_sub(total_instructions);
275            let decode_limit = remaining_instructions.saturating_add(1);
276
277            // Get instructions for this function
278            match self.get_function_instructions(binary, function, decode_limit) {
279                Ok(instructions) => {
280                    let new_total = total_instructions
281                        .checked_add(instructions.len())
282                        .ok_or_else(|| {
283                            BinaryError::control_flow(
284                                "Call-graph instruction count overflowed usize",
285                            )
286                        })?;
287                    if new_total > self.config.max_total_instructions {
288                        return Err(BinaryError::control_flow(format!(
289                            "Decoded instruction count {new_total} exceeds configured CallGraphConfig::max_total_instructions {}",
290                            self.config.max_total_instructions
291                        )));
292                    }
293                    total_instructions = new_total;
294                    successful_functions += 1;
295
296                    // Analyze instructions for calls
297                    for instruction in &instructions {
298                        if let Some(edge) = self.analyze_call_instruction(
299                            instruction,
300                            function.start_address,
301                            address_to_node,
302                        ) {
303                            edges.try_reserve(1).map_err(|error| {
304                                Self::allocation_error("call-graph edge", error)
305                            })?;
306                            edges.push(edge);
307                        }
308                    }
309
310                    if self.config.detect_tail_calls
311                        && let Some(edge) =
312                            self.detect_tail_call(function, address_to_node, &instructions)
313                    {
314                        edges
315                            .try_reserve(1)
316                            .map_err(|error| Self::allocation_error("tail-call edge", error))?;
317                        edges.push(edge);
318                    }
319                }
320                Err(error) => last_error = Some(error),
321            }
322        }
323
324        if successful_functions == 0
325            && let Some(error) = last_error
326        {
327            return Err(error);
328        }
329
330        Ok(Self::merge_edges(edges))
331    }
332
333    /// Get instructions for a function
334    fn get_function_instructions(
335        &self,
336        binary: &BinaryFile,
337        function: &Function,
338        max_instructions: usize,
339    ) -> Result<Vec<Instruction>> {
340        // Find the section containing this function
341        for section in binary.sections() {
342            let start = section.address;
343            let Some(end) = start.checked_add(section.size) else {
344                continue;
345            };
346
347            if section.permissions.execute
348                && function.start_address >= start
349                && function.start_address < end
350            {
351                let function_size = usize::try_from(function.size).unwrap_or(usize::MAX);
352                let data = section_file_data(
353                    binary,
354                    section,
355                    function.start_address - start,
356                    Some(function_size),
357                )?;
358                let length = data.len();
359                if length == 0 {
360                    return Ok(Vec::new());
361                }
362
363                let disassembler = Disassembler::with_config(
364                    binary.architecture(),
365                    DisassemblyConfig {
366                        max_instructions,
367                        ..DisassemblyConfig::default()
368                    },
369                )?;
370                return disassembler.disassemble_at(data, function.start_address, length);
371            }
372        }
373
374        Err(BinaryError::invalid_data(
375            "Function bytes not found in any executable section",
376        ))
377    }
378
379    fn ensure_function_limit(&self, actual: usize) -> Result<()> {
380        if actual > self.config.max_functions {
381            return Err(BinaryError::control_flow(format!(
382                "Discovered function count {actual} exceeds configured CallGraphConfig::max_functions {}",
383                self.config.max_functions
384            )));
385        }
386        Ok(())
387    }
388
389    fn allocation_error(context: &str, error: std::collections::TryReserveError) -> BinaryError {
390        BinaryError::control_flow(format!("Unable to reserve {context}: {error}"))
391    }
392
393    /// Analyze a single instruction for call patterns
394    fn analyze_call_instruction(
395        &self,
396        instruction: &Instruction,
397        caller_address: u64,
398        address_to_node: &HashMap<u64, usize>,
399    ) -> Option<CallGraphEdge> {
400        match &instruction.flow {
401            crate::types::ControlFlow::Call(target_address) => {
402                // Direct call
403                if address_to_node.contains_key(target_address) {
404                    let call_site = CallSite {
405                        address: instruction.address,
406                        instruction_bytes: instruction.bytes.clone(),
407                        context: CallContext::Normal,
408                    };
409
410                    let call_type = if *target_address == caller_address {
411                        CallType::Recursive
412                    } else {
413                        CallType::Direct
414                    };
415
416                    return Some(CallGraphEdge {
417                        caller: caller_address,
418                        callee: Some(*target_address),
419                        call_type,
420                        call_sites: vec![call_site],
421                    });
422                }
423            }
424            _ => {
425                // Check for indirect calls if enabled
426                if self.config.analyze_indirect_calls {
427                    return self.analyze_indirect_call(instruction, caller_address);
428                }
429            }
430        }
431
432        None
433    }
434
435    /// Analyze indirect call patterns
436    fn analyze_indirect_call(
437        &self,
438        instruction: &Instruction,
439        caller_address: u64,
440    ) -> Option<CallGraphEdge> {
441        // Direct calls have already been represented by ControlFlow::Call. Remaining
442        // call-family instructions are indirect, including register operands.
443        let mnemonic = instruction.mnemonic.to_ascii_lowercase();
444        if matches!(
445            mnemonic.as_str(),
446            "call" | "callq" | "blr" | "blx" | "jalr" | "bctrl"
447        ) {
448            // This is an indirect call through memory or register
449            let call_site = CallSite {
450                address: instruction.address,
451                instruction_bytes: instruction.bytes.clone(),
452                context: CallContext::Normal,
453            };
454
455            // For indirect calls, we can't determine the exact target at static analysis time
456            // In a real implementation, this would require more sophisticated analysis
457            return Some(CallGraphEdge {
458                caller: caller_address,
459                callee: None,
460                call_type: CallType::Indirect,
461                call_sites: vec![call_site],
462            });
463        }
464
465        None
466    }
467
468    fn merge_edges(mut edges: Vec<CallGraphEdge>) -> Vec<CallGraphEdge> {
469        edges.sort_by(|left, right| {
470            left.caller
471                .cmp(&right.caller)
472                .then_with(|| left.callee.cmp(&right.callee))
473                .then_with(|| {
474                    Self::call_type_rank(&left.call_type)
475                        .cmp(&Self::call_type_rank(&right.call_type))
476                })
477        });
478
479        let mut merged: Vec<CallGraphEdge> = Vec::with_capacity(edges.len());
480        for mut edge in edges {
481            if let Some(existing) = merged.last_mut().filter(|existing| {
482                existing.caller == edge.caller
483                    && existing.callee == edge.callee
484                    && existing.call_type == edge.call_type
485            }) {
486                existing.call_sites.append(&mut edge.call_sites);
487                continue;
488            }
489            merged.push(edge);
490        }
491
492        for edge in &mut merged {
493            edge.call_sites.sort_by(|left, right| {
494                left.address
495                    .cmp(&right.address)
496                    .then_with(|| left.instruction_bytes.cmp(&right.instruction_bytes))
497            });
498            edge.call_sites.dedup_by(|left, right| {
499                left.address == right.address
500                    && left.instruction_bytes == right.instruction_bytes
501                    && left.context == right.context
502            });
503        }
504        merged
505    }
506
507    fn call_type_rank(call_type: &CallType) -> u8 {
508        match call_type {
509            CallType::Direct => 0,
510            CallType::Indirect => 1,
511            CallType::TailCall => 2,
512            CallType::Virtual => 3,
513            CallType::Recursive => 4,
514            CallType::Conditional => 5,
515        }
516    }
517
518    /// Detect a tail-call candidate from one already-decoded function.
519    fn detect_tail_call(
520        &self,
521        function: &Function,
522        address_to_node: &HashMap<u64, usize>,
523        instructions: &[Instruction],
524    ) -> Option<CallGraphEdge> {
525        // Look for a final direct jump to another discovered function.
526        instructions
527            .last()
528            .and_then(|last| {
529                if let crate::types::ControlFlow::Jump(target) = &last.flow {
530                    (*target != function.start_address && address_to_node.contains_key(target))
531                        .then_some((last, *target))
532                } else {
533                    None
534                }
535            })
536            .map(|(last_instruction, target)| {
537                let call_site = CallSite {
538                    address: last_instruction.address,
539                    instruction_bytes: last_instruction.bytes.clone(),
540                    context: CallContext::Normal,
541                };
542
543                CallGraphEdge {
544                    caller: function.start_address,
545                    callee: Some(target),
546                    call_type: CallType::TailCall,
547                    call_sites: vec![call_site],
548                }
549            })
550    }
551
552    /// Update node in-degree and out-degree based on edges
553    fn update_node_degrees(&self, nodes: &mut [CallGraphNode], edges: &[CallGraphEdge]) {
554        let mut callers: HashMap<u64, HashSet<u64>> = HashMap::new();
555        let mut callees: HashMap<u64, HashSet<Option<u64>>> = HashMap::new();
556
557        for edge in edges {
558            callees.entry(edge.caller).or_default().insert(edge.callee);
559            if let Some(callee) = edge.callee {
560                callers.entry(callee).or_default().insert(edge.caller);
561            }
562        }
563
564        for node in nodes {
565            node.in_degree = callers
566                .get(&node.function_address)
567                .map_or(0, |values| u32::try_from(values.len()).unwrap_or(u32::MAX));
568            node.out_degree = callees
569                .get(&node.function_address)
570                .map_or(0, |values| u32::try_from(values.len()).unwrap_or(u32::MAX));
571        }
572    }
573
574    /// Detect recursive functions
575    fn detect_recursion(&self, nodes: &mut [CallGraphNode], edges: &[CallGraphEdge]) {
576        let recursive_functions: HashSet<u64> = cyclic_components(nodes, edges)
577            .into_iter()
578            .flatten()
579            .collect();
580
581        // Update nodes
582        for node in nodes {
583            node.is_recursive = recursive_functions.contains(&node.function_address);
584        }
585    }
586
587    /// Find entry points in the call graph
588    fn find_entry_points(&self, binary: &BinaryFile, functions: &[Function]) -> Vec<u64> {
589        let mut entry_points = Vec::new();
590
591        // Add main entry point
592        if let Some(entry) = binary.entry_point() {
593            entry_points.push(entry);
594        }
595
596        // Add other known entry points
597        for function in functions {
598            match function.function_type {
599                crate::types::FunctionType::Entrypoint | crate::types::FunctionType::Main
600                    if !entry_points.contains(&function.start_address) =>
601                {
602                    entry_points.push(function.start_address);
603                }
604                _ => {}
605            }
606        }
607
608        entry_points.sort_unstable();
609        entry_points.dedup();
610        entry_points
611    }
612
613    /// Compute call depths from entry points using BFS
614    fn compute_call_depths(&self, call_graph: &mut CallGraph) -> Result<()> {
615        let (address_to_node, adjacency) = Self::graph_index(call_graph)?;
616
617        for node in &mut call_graph.nodes {
618            node.call_depth = None;
619        }
620
621        // Multi-source BFS computes the shortest distance from any entry point.
622        let mut queue = VecDeque::new();
623        queue
624            .try_reserve(call_graph.entry_points.len().min(call_graph.nodes.len()))
625            .map_err(|error| Self::allocation_error("call-depth queue", error))?;
626        for &entry_point in &call_graph.entry_points {
627            if let Some(&node_index) = address_to_node.get(&entry_point) {
628                call_graph.nodes[node_index].call_depth = Some(0);
629                queue.push_back(entry_point);
630            }
631        }
632
633        while let Some(current_addr) = queue.pop_front() {
634            let node_index = address_to_node[&current_addr];
635            let depth = call_graph.nodes[node_index].call_depth.unwrap_or(0);
636            if self
637                .config
638                .max_labeled_call_depth
639                .is_some_and(|maximum| depth >= maximum)
640            {
641                continue;
642            }
643
644            let next_depth = depth.saturating_add(1);
645            if let Some(neighbors) = adjacency.get(&current_addr) {
646                for &neighbor in neighbors {
647                    let neighbor_index = address_to_node[&neighbor];
648                    if call_graph.nodes[neighbor_index].call_depth.is_none() {
649                        call_graph.nodes[neighbor_index].call_depth = Some(next_depth);
650                        queue.push_back(neighbor);
651                    }
652                }
653            }
654        }
655
656        Ok(())
657    }
658
659    /// Find unreachable functions independently of the call-depth labeling cap.
660    fn find_unreachable_functions(&self, call_graph: &CallGraph) -> Result<Vec<u64>> {
661        let (address_to_node, adjacency) = Self::graph_index(call_graph)?;
662        let mut reachable = HashSet::new();
663        reachable
664            .try_reserve(call_graph.nodes.len())
665            .map_err(|error| Self::allocation_error("reachable function set", error))?;
666        let mut queue = VecDeque::new();
667        queue
668            .try_reserve(call_graph.nodes.len())
669            .map_err(|error| Self::allocation_error("reachability queue", error))?;
670
671        for &entry_point in &call_graph.entry_points {
672            if address_to_node.contains_key(&entry_point) && reachable.insert(entry_point) {
673                queue.push_back(entry_point);
674            }
675        }
676
677        while let Some(current) = queue.pop_front() {
678            if let Some(neighbors) = adjacency.get(&current) {
679                for &neighbor in neighbors {
680                    if reachable.insert(neighbor) {
681                        queue.push_back(neighbor);
682                    }
683                }
684            }
685        }
686
687        let mut unreachable = Vec::new();
688        unreachable
689            .try_reserve(call_graph.nodes.len().saturating_sub(reachable.len()))
690            .map_err(|error| Self::allocation_error("unreachable function list", error))?;
691        unreachable.extend(
692            call_graph
693                .nodes
694                .iter()
695                .filter(|node| !reachable.contains(&node.function_address))
696                .map(|node| node.function_address),
697        );
698        Ok(unreachable)
699    }
700
701    fn graph_index(call_graph: &CallGraph) -> Result<(AddressToNode, CallAdjacency)> {
702        let mut address_to_node = HashMap::new();
703        address_to_node
704            .try_reserve(call_graph.nodes.len())
705            .map_err(|error| Self::allocation_error("call-graph address index", error))?;
706        for (index, node) in call_graph.nodes.iter().enumerate() {
707            address_to_node.insert(node.function_address, index);
708        }
709
710        let mut adjacency: HashMap<u64, Vec<u64>> = HashMap::new();
711        adjacency
712            .try_reserve(call_graph.nodes.len())
713            .map_err(|error| Self::allocation_error("call-graph adjacency", error))?;
714        for edge in &call_graph.edges {
715            if !matches!(edge.call_type, CallType::Indirect)
716                && address_to_node.contains_key(&edge.caller)
717                && let Some(callee) = edge
718                    .callee
719                    .filter(|callee| address_to_node.contains_key(callee))
720            {
721                let neighbors = adjacency.entry(edge.caller).or_default();
722                neighbors
723                    .try_reserve(1)
724                    .map_err(|error| Self::allocation_error("call-graph neighbor", error))?;
725                neighbors.push(callee);
726            }
727        }
728        for neighbors in adjacency.values_mut() {
729            neighbors.sort_unstable();
730            neighbors.dedup();
731        }
732
733        Ok((address_to_node, adjacency))
734    }
735
736    /// Compute call graph statistics
737    fn compute_statistics(&self, call_graph: &CallGraph) -> CallGraphStatistics {
738        let total_functions = call_graph.nodes.len();
739        let total_calls = call_graph.edges.iter().fold(0_usize, |count, edge| {
740            count.saturating_add(edge.call_sites.len())
741        });
742
743        let direct_calls = call_graph
744            .edges
745            .iter()
746            .filter(|edge| matches!(edge.call_type, CallType::Direct | CallType::Recursive))
747            .fold(0_usize, |count, edge| {
748                count.saturating_add(edge.call_sites.len())
749            });
750
751        let indirect_calls = call_graph
752            .edges
753            .iter()
754            .filter(|edge| matches!(edge.call_type, CallType::Indirect))
755            .fold(0_usize, |count, edge| {
756                count.saturating_add(edge.call_sites.len())
757            });
758
759        let recursive_functions = call_graph
760            .nodes
761            .iter()
762            .filter(|node| node.is_recursive)
763            .count();
764
765        let leaf_functions = call_graph
766            .nodes
767            .iter()
768            .filter(|node| node.out_degree == 0)
769            .count();
770
771        let entry_points = call_graph.entry_points.len();
772        let unreachable_functions = call_graph.unreachable_functions.len();
773
774        let max_call_depth = call_graph
775            .nodes
776            .iter()
777            .filter_map(|node| node.call_depth)
778            .max()
779            .unwrap_or(0);
780
781        let depths: Vec<u32> = call_graph
782            .nodes
783            .iter()
784            .filter_map(|node| node.call_depth)
785            .collect();
786
787        let average_call_depth = if !depths.is_empty() {
788            depths.iter().map(|&depth| f64::from(depth)).sum::<f64>() / depths.len() as f64
789        } else {
790            0.0
791        };
792
793        let cyclic_dependencies = self.count_cyclic_dependencies(call_graph);
794
795        CallGraphStatistics {
796            total_functions,
797            total_calls,
798            direct_calls,
799            indirect_calls,
800            recursive_functions,
801            leaf_functions,
802            entry_points,
803            unreachable_functions,
804            max_call_depth,
805            average_call_depth,
806            cyclic_dependencies,
807        }
808    }
809
810    /// Count strongly connected components (cyclic dependencies)
811    fn count_cyclic_dependencies(&self, call_graph: &CallGraph) -> usize {
812        cyclic_components(&call_graph.nodes, &call_graph.edges).len()
813    }
814}
815
816fn cyclic_components(nodes: &[CallGraphNode], edges: &[CallGraphEdge]) -> Vec<Vec<u64>> {
817    let mut graph = DiGraphMap::<u64, ()>::new();
818    for node in nodes {
819        graph.add_node(node.function_address);
820    }
821    for edge in edges {
822        if !matches!(edge.call_type, CallType::Indirect)
823            && graph.contains_node(edge.caller)
824            && let Some(callee) = edge.callee.filter(|callee| graph.contains_node(*callee))
825        {
826            graph.add_edge(edge.caller, callee, ());
827        }
828    }
829
830    let mut components: Vec<Vec<u64>> = kosaraju_scc(&graph)
831        .into_iter()
832        .filter(|component| {
833            component.len() > 1
834                || component
835                    .first()
836                    .is_some_and(|&node| graph.contains_edge(node, node))
837        })
838        .collect();
839    for component in &mut components {
840        component.sort_unstable();
841    }
842    components.sort();
843    components
844}
845
846/// Configuration for DOT export
847#[derive(Debug, Clone)]
848pub struct DotConfig {
849    /// Include function addresses in labels
850    pub include_addresses: bool,
851    /// Color nodes by type
852    pub color_by_type: bool,
853    /// Show call counts on edges
854    pub show_call_counts: bool,
855    /// Cluster nodes by module
856    pub cluster_by_module: bool,
857    /// Maximum number of nodes to include
858    pub max_nodes: Option<usize>,
859}
860
861impl Default for DotConfig {
862    fn default() -> Self {
863        Self {
864            include_addresses: true,
865            color_by_type: true,
866            show_call_counts: false,
867            cluster_by_module: false,
868            max_nodes: Some(1000),
869        }
870    }
871}
872
873/// Trait for call graph exporters
874pub trait CallGraphExporter {
875    /// Export call graph to string format
876    fn export(&self, graph: &CallGraph) -> Result<String>;
877}
878
879/// DOT format exporter for Graphviz visualization
880pub struct DotExporter {
881    config: DotConfig,
882}
883
884impl DotExporter {
885    /// Create new DOT exporter with configuration
886    pub fn new(config: DotConfig) -> Self {
887        Self { config }
888    }
889
890    /// Create new DOT exporter with default configuration
891    pub fn new_default() -> Self {
892        Self {
893            config: DotConfig::default(),
894        }
895    }
896}
897
898impl CallGraphExporter for DotExporter {
899    fn export(&self, graph: &CallGraph) -> Result<String> {
900        if self.config.cluster_by_module {
901            return Err(BinaryError::feature_not_available(
902                "DOT module clustering is not implemented",
903            ));
904        }
905        let mut dot = String::new();
906
907        // DOT header
908        dot.push_str("digraph CallGraph {\n");
909        dot.push_str("  rankdir=TB;\n");
910        dot.push_str("  node [shape=box, style=filled];\n");
911        dot.push_str("  edge [arrowhead=normal];\n\n");
912
913        // Limit nodes if configured
914        let nodes_to_include = if let Some(max_nodes) = self.config.max_nodes {
915            &graph.nodes[..std::cmp::min(max_nodes, graph.nodes.len())]
916        } else {
917            &graph.nodes
918        };
919
920        // Export nodes
921        for node in nodes_to_include {
922            let escaped_name = escape_dot_label(&node.function_name);
923            let label = if self.config.include_addresses {
924                format!("{}\\n0x{:x}", escaped_name, node.function_address)
925            } else {
926                escaped_name
927            };
928
929            let color = if self.config.color_by_type {
930                match node.node_type {
931                    NodeType::EntryPoint => "lightgreen",
932                    NodeType::Library => "lightblue",
933                    NodeType::External => "lightyellow",
934                    NodeType::Internal => "lightgray",
935                    NodeType::Indirect => "orange",
936                    NodeType::Virtual => "purple",
937                    NodeType::Unknown => "pink",
938                }
939            } else {
940                "lightgray"
941            };
942
943            dot.push_str(&format!(
944                "  \"0x{:x}\" [label=\"{}\", fillcolor=\"{}\"];\n",
945                node.function_address, label, color
946            ));
947        }
948
949        dot.push('\n');
950
951        // Export edges
952        let node_addresses: HashSet<u64> = nodes_to_include
953            .iter()
954            .map(|n| n.function_address)
955            .collect();
956        if graph
957            .edges
958            .iter()
959            .any(|edge| edge.callee.is_none() && node_addresses.contains(&edge.caller))
960        {
961            dot.push_str(
962                "  \"__unknown_target\" [label=\"unknown target\", shape=ellipse, fillcolor=orange];\n",
963            );
964        }
965
966        for edge in &graph.edges {
967            if !node_addresses.contains(&edge.caller) {
968                continue;
969            }
970
971            let target = match edge.callee {
972                Some(callee) if node_addresses.contains(&callee) => format!("\"0x{callee:x}\""),
973                None => "\"__unknown_target\"".to_string(),
974                Some(_) => continue,
975            };
976            let mut attributes = Vec::new();
977            if self.config.show_call_counts {
978                attributes.push(format!("label=\"{}\"", edge.call_sites.len()));
979            }
980            match edge.call_type {
981                CallType::Direct => {}
982                CallType::Indirect => attributes.push("style=dashed".to_string()),
983                CallType::TailCall => attributes.push("color=red".to_string()),
984                CallType::Virtual => attributes.push("color=purple".to_string()),
985                CallType::Recursive => {
986                    attributes.push("color=green".to_string());
987                    attributes.push("style=bold".to_string());
988                }
989                CallType::Conditional => attributes.push("color=orange".to_string()),
990            }
991            let attributes = if attributes.is_empty() {
992                String::new()
993            } else {
994                format!(" [{}]", attributes.join(", "))
995            };
996
997            dot.push_str(&format!(
998                "  \"0x{:x}\" -> {target}{attributes};\n",
999                edge.caller
1000            ));
1001        }
1002
1003        dot.push_str("}\n");
1004        Ok(dot)
1005    }
1006}
1007
1008fn escape_dot_label(value: &str) -> String {
1009    let mut escaped = String::with_capacity(value.len());
1010    for character in value.chars() {
1011        match character {
1012            '\\' => escaped.push_str("\\\\"),
1013            '"' => escaped.push_str("\\\""),
1014            '\n' => escaped.push_str("\\n"),
1015            '\r' => escaped.push_str("\\r"),
1016            '\t' => escaped.push_str("\\t"),
1017            character if character.is_control() => {
1018                use std::fmt::Write as _;
1019                let _ = write!(escaped, "\\u{:04x}", character as u32);
1020            }
1021            character => escaped.push(character),
1022        }
1023    }
1024    escaped
1025}
1026
1027/// JSON exporter for programmatic analysis
1028pub struct JsonExporter;
1029
1030impl CallGraphExporter for JsonExporter {
1031    fn export(&self, graph: &CallGraph) -> Result<String> {
1032        #[cfg(feature = "serde-support")]
1033        {
1034            serde_json::to_string_pretty(graph)
1035                .map_err(|e| BinaryError::invalid_data(format!("JSON serialization failed: {}", e)))
1036        }
1037        #[cfg(not(feature = "serde-support"))]
1038        {
1039            let _ = graph; // Suppress unused warning
1040            Err(BinaryError::feature_not_available("serde-support"))
1041        }
1042    }
1043}
1044
1045impl CallGraph {
1046    /// Export call graph to DOT format for Graphviz
1047    pub fn to_dot(&self) -> Result<String> {
1048        let exporter = DotExporter::new_default();
1049        exporter.export(self)
1050    }
1051
1052    /// Export call graph to DOT format with custom configuration
1053    pub fn to_dot_with_config(&self, config: DotConfig) -> Result<String> {
1054        let exporter = DotExporter::new(config);
1055        exporter.export(self)
1056    }
1057
1058    /// Export call graph to JSON format
1059    pub fn to_json(&self) -> Result<String> {
1060        let exporter = JsonExporter;
1061        exporter.export(self)
1062    }
1063
1064    /// Return cyclic strongly connected components in deterministic address order.
1065    pub fn detect_cycles(&self) -> Vec<Vec<u64>> {
1066        cyclic_components(&self.nodes, &self.edges)
1067    }
1068}
1069
1070/// Analyze binary call graph
1071pub fn analyze_binary(binary: &BinaryFile) -> Result<CallGraph> {
1072    let analyzer = CallGraphAnalyzer::new_default();
1073    analyzer.analyze_binary(binary)
1074}
1075
1076/// Analyze binary call graph with custom configuration
1077pub fn analyze_binary_with_config(
1078    binary: &BinaryFile,
1079    config: CallGraphConfig,
1080) -> Result<CallGraph> {
1081    let analyzer = CallGraphAnalyzer::new(config);
1082    analyzer.analyze_binary(binary)
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    use super::*;
1088    use crate::types::*;
1089
1090    struct TestFormat {
1091        sections: Vec<Section>,
1092        symbols: Vec<Symbol>,
1093        metadata: BinaryMetadata,
1094    }
1095
1096    impl BinaryFormatTrait for TestFormat {
1097        fn format_type(&self) -> BinaryFormat {
1098            BinaryFormat::Raw
1099        }
1100
1101        fn architecture(&self) -> Architecture {
1102            Architecture::X86_64
1103        }
1104
1105        fn entry_point(&self) -> Option<u64> {
1106            None
1107        }
1108
1109        fn sections(&self) -> &[Section] {
1110            &self.sections
1111        }
1112
1113        fn symbols(&self) -> &[Symbol] {
1114            &self.symbols
1115        }
1116
1117        fn imports(&self) -> &[Import] {
1118            &[]
1119        }
1120
1121        fn exports(&self) -> &[Export] {
1122            &[]
1123        }
1124
1125        fn metadata(&self) -> &BinaryMetadata {
1126            &self.metadata
1127        }
1128    }
1129
1130    fn function_symbol(name: &str, address: u64) -> Symbol {
1131        Symbol {
1132            name: name.to_string(),
1133            demangled_name: None,
1134            address,
1135            size: 1,
1136            symbol_type: SymbolType::Function,
1137            binding: SymbolBinding::Global,
1138            visibility: SymbolVisibility::Default,
1139            section_index: None,
1140        }
1141    }
1142
1143    fn test_binary(data: Vec<u8>, sections: Vec<Section>, symbols: Vec<Symbol>) -> BinaryFile {
1144        let metadata = BinaryMetadata {
1145            size: data.len(),
1146            format: BinaryFormat::Raw,
1147            architecture: Architecture::X86_64,
1148            entry_point: None,
1149            base_address: None,
1150            timestamp: None,
1151            compiler_info: None,
1152            endian: Endianness::Little,
1153            security_features: SecurityFeatures::default(),
1154        };
1155
1156        BinaryFile {
1157            data,
1158            parsed: Box::new(TestFormat {
1159                sections,
1160                symbols,
1161                metadata,
1162            }),
1163        }
1164    }
1165
1166    fn function_analysis_binary(include_valid_function: bool) -> BinaryFile {
1167        let data = vec![0xc3];
1168        let mut sections = vec![Section {
1169            name: ".invalid".to_string(),
1170            address: 0x1000,
1171            size: 1,
1172            offset: 2,
1173            file_size: 1,
1174            permissions: SectionPermissions {
1175                read: true,
1176                write: false,
1177                execute: true,
1178            },
1179            section_type: SectionType::Code,
1180            data: None,
1181        }];
1182        let mut symbols = vec![function_symbol("invalid", 0x1000)];
1183
1184        if include_valid_function {
1185            sections.push(Section {
1186                name: ".valid".to_string(),
1187                address: 0x2000,
1188                size: 1,
1189                offset: 0,
1190                file_size: 1,
1191                permissions: SectionPermissions {
1192                    read: true,
1193                    write: false,
1194                    execute: true,
1195                },
1196                section_type: SectionType::Code,
1197                data: Some(data.clone()),
1198            });
1199            symbols.push(function_symbol("valid", 0x2000));
1200        }
1201
1202        test_binary(data, sections, symbols)
1203    }
1204
1205    #[test]
1206    fn test_analyzer_creation() {
1207        let analyzer = CallGraphAnalyzer::new_default();
1208        assert!(analyzer.config.analyze_indirect_calls);
1209        assert!(analyzer.config.detect_tail_calls);
1210        assert_eq!(analyzer.config.max_functions, 10_000);
1211        assert_eq!(analyzer.config.max_total_instructions, 1_000_000);
1212    }
1213
1214    #[test]
1215    fn analyze_binary_returns_error_when_every_function_fails() {
1216        let binary = function_analysis_binary(false);
1217        let analyzer = CallGraphAnalyzer::new_default();
1218
1219        let error = analyzer.analyze_binary(&binary).unwrap_err();
1220
1221        assert!(
1222            matches!(error, BinaryError::InvalidData(message) if message.contains("beyond the end of the file"))
1223        );
1224    }
1225
1226    #[test]
1227    fn analyze_binary_preserves_partial_function_success() {
1228        let binary = function_analysis_binary(true);
1229        let analyzer = CallGraphAnalyzer::new(CallGraphConfig {
1230            max_functions: 2,
1231            max_total_instructions: 1,
1232            ..CallGraphConfig::default()
1233        });
1234
1235        let graph = analyzer.analyze_binary(&binary).unwrap();
1236
1237        assert_eq!(graph.nodes.len(), 2);
1238        assert!(graph.nodes.iter().any(|node| node.function_name == "valid"));
1239    }
1240
1241    #[test]
1242    fn analyze_binary_returns_error_when_function_has_no_executable_section() {
1243        let binary = test_binary(
1244            vec![0xc3],
1245            Vec::new(),
1246            vec![function_symbol("missing", 0x3000)],
1247        );
1248        let analyzer = CallGraphAnalyzer::new_default();
1249
1250        let error = analyzer.analyze_binary(&binary).unwrap_err();
1251
1252        assert!(
1253            matches!(error, BinaryError::InvalidData(message) if message == "Function bytes not found in any executable section")
1254        );
1255    }
1256
1257    #[test]
1258    fn containing_zero_length_file_range_counts_as_success() {
1259        let binary = test_binary(
1260            vec![0xc3],
1261            vec![Section {
1262                name: ".empty".to_string(),
1263                address: 0x3000,
1264                size: 1,
1265                offset: 1,
1266                file_size: 0,
1267                permissions: SectionPermissions {
1268                    read: true,
1269                    write: false,
1270                    execute: true,
1271                },
1272                section_type: SectionType::Code,
1273                data: None,
1274            }],
1275            vec![function_symbol("empty", 0x3000)],
1276        );
1277        let analyzer = CallGraphAnalyzer::new_default();
1278
1279        let graph = analyzer.analyze_binary(&binary).unwrap();
1280
1281        assert_eq!(graph.nodes.len(), 1);
1282        assert!(graph.edges.is_empty());
1283    }
1284
1285    #[test]
1286    fn analyze_binary_rejects_function_count_above_configured_cap() {
1287        let binary = function_analysis_binary(true);
1288        let analyzer = CallGraphAnalyzer::new(CallGraphConfig {
1289            max_functions: 1,
1290            ..CallGraphConfig::default()
1291        });
1292
1293        let error = analyzer.analyze_binary(&binary).unwrap_err();
1294
1295        assert!(
1296            matches!(error, BinaryError::ControlFlowError(message) if message.contains("max_functions 1"))
1297        );
1298    }
1299
1300    #[test]
1301    fn analyze_binary_rejects_total_instructions_above_configured_cap() {
1302        let binary = function_analysis_binary(true);
1303        let analyzer = CallGraphAnalyzer::new(CallGraphConfig {
1304            max_total_instructions: 0,
1305            ..CallGraphConfig::default()
1306        });
1307
1308        let error = analyzer.analyze_binary(&binary).unwrap_err();
1309
1310        assert!(
1311            matches!(error, BinaryError::ControlFlowError(message) if message.contains("max_total_instructions 0"))
1312        );
1313    }
1314
1315    #[test]
1316    fn test_library_function_detection() {
1317        let analyzer = CallGraphAnalyzer::new_default();
1318        assert!(analyzer.is_library_function("printf"));
1319        assert!(analyzer.is_library_function("libc_start_main"));
1320        assert!(analyzer.is_library_function("__stack_chk_fail"));
1321        assert!(!analyzer.is_library_function("user_function"));
1322        assert!(!analyzer.is_library_function("carefree_user"));
1323        assert!(!analyzer.is_library_function("main"));
1324    }
1325
1326    #[test]
1327    fn indirect_call_setting_gates_unknown_target_edges() {
1328        let instruction = Instruction {
1329            address: 0x1000,
1330            bytes: vec![0xff, 0xd0],
1331            mnemonic: "call".to_string(),
1332            operands: "rax".to_string(),
1333            category: InstructionCategory::Control,
1334            flow: crate::types::ControlFlow::Sequential,
1335            size: 2,
1336        };
1337        let enabled = CallGraphAnalyzer::new_default();
1338        let disabled = CallGraphAnalyzer::new(CallGraphConfig {
1339            analyze_indirect_calls: false,
1340            ..CallGraphConfig::default()
1341        });
1342
1343        let detected = enabled
1344            .analyze_call_instruction(&instruction, 0x1000, &HashMap::new())
1345            .unwrap();
1346        assert_eq!(detected.callee, None);
1347        assert!(
1348            disabled
1349                .analyze_call_instruction(&instruction, 0x1000, &HashMap::new())
1350                .is_none()
1351        );
1352    }
1353
1354    #[test]
1355    fn test_node_type_classification() {
1356        let analyzer = CallGraphAnalyzer::new_default();
1357
1358        let entry_function = Function {
1359            name: "_start".to_string(),
1360            start_address: 0x1000,
1361            end_address: 0x1100,
1362            size: 256,
1363            function_type: FunctionType::Entrypoint,
1364            calling_convention: None,
1365            parameters: Vec::new(),
1366            return_type: None,
1367        };
1368
1369        // Create a minimal ELF binary for testing (minimal header)
1370        let elf_data = vec![
1371            0x7f, 0x45, 0x4c, 0x46, // ELF magic
1372            0x02, // 64-bit
1373            0x01, // Little endian
1374            0x01, // Version
1375            0x00, // System V ABI
1376            0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Padding
1377            0x02, 0x00, // Executable file
1378            0x3e, 0x00, // x86-64
1379        ];
1380
1381        // For this test, we don't need a fully valid binary, just enough to classify node types
1382        // The actual binary parsing might fail, but we can still test the logic
1383        match crate::BinaryFile::parse(&elf_data) {
1384            Ok(binary) => {
1385                let node_type = analyzer.classify_node_type(&entry_function, &binary);
1386                assert_eq!(node_type, NodeType::EntryPoint);
1387            }
1388            Err(_) => {
1389                // If parsing fails (which is expected for minimal data), just test the function type detection
1390                // This is acceptable since we're primarily testing the classification logic
1391                assert_eq!(entry_function.function_type, FunctionType::Entrypoint);
1392            }
1393        }
1394    }
1395
1396    fn node(address: u64, name: &str) -> CallGraphNode {
1397        CallGraphNode {
1398            function_address: address,
1399            function_name: name.to_string(),
1400            node_type: NodeType::Internal,
1401            complexity: 0,
1402            in_degree: 0,
1403            out_degree: 0,
1404            is_recursive: false,
1405            call_depth: None,
1406        }
1407    }
1408
1409    fn edge(caller: u64, callee: u64, call_type: CallType, site: u64) -> CallGraphEdge {
1410        CallGraphEdge {
1411            caller,
1412            callee: Some(callee),
1413            call_type,
1414            call_sites: vec![CallSite {
1415                address: site,
1416                instruction_bytes: vec![0xe8],
1417                context: CallContext::Normal,
1418            }],
1419        }
1420    }
1421
1422    fn unknown_edge(caller: u64, site: u64) -> CallGraphEdge {
1423        CallGraphEdge {
1424            caller,
1425            callee: None,
1426            call_type: CallType::Indirect,
1427            call_sites: vec![CallSite {
1428                address: site,
1429                instruction_bytes: vec![0xff, 0xd0],
1430                context: CallContext::Normal,
1431            }],
1432        }
1433    }
1434
1435    fn graph(nodes: Vec<CallGraphNode>, edges: Vec<CallGraphEdge>) -> CallGraph {
1436        CallGraph {
1437            nodes,
1438            edges,
1439            entry_points: vec![1],
1440            unreachable_functions: Vec::new(),
1441            statistics: CallGraphStatistics::default(),
1442        }
1443    }
1444
1445    #[test]
1446    fn dot_export_handles_plain_direct_edges_and_escapes_labels() {
1447        let graph = graph(
1448            vec![node(1, "quoted\"name\nline"), node(2, "callee")],
1449            vec![edge(1, 2, CallType::Direct, 10)],
1450        );
1451
1452        let dot = DotExporter::new_default().export(&graph).unwrap();
1453
1454        assert!(dot.contains("quoted\\\"name\\nline"));
1455        assert!(dot.contains("\"0x1\" -> \"0x2\";"));
1456    }
1457
1458    #[test]
1459    fn repeated_calls_are_merged_with_sorted_call_sites() {
1460        let edges = vec![
1461            edge(1, 2, CallType::Direct, 20),
1462            edge(1, 2, CallType::Direct, 10),
1463            edge(1, 2, CallType::Direct, 10),
1464        ];
1465
1466        let merged = CallGraphAnalyzer::merge_edges(edges);
1467
1468        assert_eq!(merged.len(), 1);
1469        assert_eq!(merged[0].call_sites.len(), 2);
1470        assert_eq!(merged[0].call_sites[0].address, 10);
1471        assert_eq!(merged[0].call_sites[1].address, 20);
1472    }
1473
1474    #[test]
1475    fn unknown_target_does_not_conflate_with_function_at_zero() {
1476        let analyzer = CallGraphAnalyzer::new_default();
1477        let mut nodes = vec![node(0, "zero"), node(1, "caller")];
1478        let edges = vec![unknown_edge(1, 10)];
1479
1480        analyzer.update_node_degrees(&mut nodes, &edges);
1481        let dot = DotExporter::new_default()
1482            .export(&graph(nodes.clone(), edges))
1483            .unwrap();
1484
1485        assert_eq!(nodes[0].in_degree, 0);
1486        assert_eq!(nodes[1].out_degree, 1);
1487        assert!(dot.contains("\"__unknown_target\""));
1488        assert!(dot.contains("\"0x1\" -> \"__unknown_target\""));
1489        assert!(!dot.contains("\"0x1\" -> \"0x0\""));
1490    }
1491
1492    #[test]
1493    fn cycle_detection_returns_deterministic_components() {
1494        let graph = graph(
1495            vec![node(1, "one"), node(2, "two"), node(3, "three")],
1496            vec![
1497                edge(2, 1, CallType::Direct, 20),
1498                edge(1, 2, CallType::Direct, 10),
1499                unknown_edge(3, 30),
1500            ],
1501        );
1502
1503        assert_eq!(graph.detect_cycles(), vec![vec![1, 2]]);
1504    }
1505
1506    #[cfg(not(feature = "serde-support"))]
1507    #[test]
1508    fn json_export_reports_missing_serde_feature() {
1509        let graph = graph(Vec::new(), Vec::new());
1510
1511        assert!(matches!(
1512            graph.to_json(),
1513            Err(BinaryError::FeatureNotAvailable(feature)) if feature == "serde-support"
1514        ));
1515    }
1516
1517    #[cfg(feature = "serde-support")]
1518    #[test]
1519    fn json_export_uses_null_for_unknown_call_target() {
1520        let graph = graph(vec![node(1, "caller")], vec![unknown_edge(1, 10)]);
1521
1522        let json = graph.to_json().unwrap();
1523
1524        assert!(json.contains("\"callee\": null"));
1525    }
1526
1527    #[test]
1528    fn maximum_labeled_call_depth_is_enforced() {
1529        let config = CallGraphConfig {
1530            max_labeled_call_depth: Some(1),
1531            ..CallGraphConfig::default()
1532        };
1533        let analyzer = CallGraphAnalyzer::new(config);
1534        let mut graph = graph(
1535            vec![node(1, "one"), node(2, "two"), node(3, "three")],
1536            vec![
1537                edge(1, 2, CallType::Direct, 10),
1538                edge(2, 3, CallType::Direct, 20),
1539            ],
1540        );
1541
1542        analyzer.compute_call_depths(&mut graph).unwrap();
1543
1544        assert_eq!(graph.nodes[0].call_depth, Some(0));
1545        assert_eq!(graph.nodes[1].call_depth, Some(1));
1546        assert_eq!(graph.nodes[2].call_depth, None);
1547        assert!(
1548            analyzer
1549                .find_unreachable_functions(&graph)
1550                .unwrap()
1551                .is_empty()
1552        );
1553    }
1554}