Skip to main content

profile_bee/
dwarf_unwind.rs

1//! DWARF-based unwind table generation
2//!
3//! Parses `.eh_frame` sections from ELF binaries to generate compact unwind
4//! tables that can be loaded into eBPF maps for kernel-side stack unwinding
5//! without requiring frame pointers.
6
7use std::collections::HashMap;
8use std::fs;
9use std::path::Path;
10
11use gimli::{BaseAddresses, CfaRule, EhFrame, NativeEndian, Register, RegisterRule, UnwindSection};
12use object::{Object, ObjectSection};
13use procfs::process::{MMapPath, Process};
14use profile_bee_common::{
15    ExecMapping, ProcInfo, UnwindEntry, CFA_REG_DEREF_RSP, CFA_REG_EXPRESSION, CFA_REG_PLT,
16    CFA_REG_RBP, CFA_REG_RSP, MAX_PROC_MAPS, MAX_SHARD_ENTRIES, MAX_UNWIND_SHARDS, REG_RULE_OFFSET,
17    REG_RULE_SAME_VALUE, REG_RULE_UNDEFINED, SHARD_NONE,
18};
19
20/// Build ID for uniquely identifying ELF binaries
21pub type BuildId = Vec<u8>;
22
23/// File metadata for cache lookups (avoids reading full binary)
24/// Uses (dev, ino, size, mtime) as composite key for identifying binaries
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26struct FileMetadata {
27    dev: u64,
28    ino: u64,
29    size: u64,
30    mtime_sec: i64,
31    mtime_nsec: i64,
32}
33
34impl FileMetadata {
35    /// Extract metadata from a file path using stat()
36    fn from_path(path: &Path) -> Result<Self, String> {
37        use std::os::unix::fs::MetadataExt;
38        let metadata = fs::metadata(path)
39            .map_err(|e| format!("Failed to stat {}: {}", path.display(), e))?;
40        Ok(Self {
41            dev: metadata.dev(),
42            ino: metadata.ino(),
43            size: metadata.len(),
44            mtime_sec: metadata.mtime(),
45            mtime_nsec: metadata.mtime_nsec(),
46        })
47    }
48}
49
50// x86_64 register numbers in DWARF
51const X86_64_RSP: Register = Register(7);
52const X86_64_RBP: Register = Register(6);
53const X86_64_RA: Register = Register(16);
54
55/// Generates a compact unwind table from an ELF binary's .eh_frame section
56pub fn generate_unwind_table(
57    elf_path: &Path,
58) -> Result<(Vec<UnwindEntry>, Option<BuildId>), String> {
59    let data =
60        fs::read(elf_path).map_err(|e| format!("Failed to read {}: {}", elf_path.display(), e))?;
61    generate_unwind_table_from_bytes(&data)
62}
63
64/// Generates a compact unwind table from ELF binary bytes
65/// Classify a DWARF CFA expression into a known pattern.
66///
67/// Recognizes two common expressions found in glibc/ld-linux:
68/// 1. PLT stub: `breg7(rsp)+8; breg16(rip)+0; lit15; and; lit11; ge; lit3; shl; plus`
69///    → CFA = RSP + 8 + ((RIP & 15) >= 11 ? 8 : 0)
70/// 2. Signal frame: `breg7(rsp)+N; deref`
71///    → CFA = *(RSP + N)
72fn classify_cfa_expression(
73    unwind_expr: &gimli::UnwindExpression<usize>,
74    eh_frame_data: &[u8],
75) -> (u8, i16) {
76    use gimli::Operation;
77
78    // Extract expression bytes from the section
79    let start = unwind_expr.offset;
80    let end = start + unwind_expr.length;
81    if end > eh_frame_data.len() {
82        return (CFA_REG_EXPRESSION, 0);
83    }
84    let expr_bytes = &eh_frame_data[start..end];
85    let expr = gimli::Expression(gimli::EndianSlice::new(expr_bytes, NativeEndian));
86
87    let mut ops = expr.operations(gimli::Encoding {
88        address_size: 8,
89        format: gimli::Format::Dwarf32,
90        version: 4,
91    });
92
93    // First op should be RegisterOffset { register: RSP, offset: N } (DW_OP_breg7)
94    let Ok(Some(Operation::RegisterOffset {
95        register, offset, ..
96    })) = ops.next()
97    else {
98        return (CFA_REG_EXPRESSION, 0);
99    };
100    if register != X86_64_RSP {
101        return (CFA_REG_EXPRESSION, 0);
102    }
103    let base_offset = offset;
104
105    match ops.next() {
106        // Signal frame: breg7(rsp)+N; deref → CFA = *(RSP + N)
107        Ok(Some(Operation::Deref { .. })) => {
108            let Ok(off) = i16::try_from(base_offset) else {
109                return (CFA_REG_EXPRESSION, 0);
110            };
111            (CFA_REG_DEREF_RSP, off)
112        }
113        // PLT stub: breg7(rsp)+N; breg16(rip)+0; ... → CFA = RSP + N + ((RIP&15)>=11 ? 8 : 0)
114        Ok(Some(Operation::RegisterOffset {
115            register: reg2,
116            offset: 0,
117            ..
118        })) if reg2 == X86_64_RA => {
119            let Ok(off) = i16::try_from(base_offset) else {
120                return (CFA_REG_EXPRESSION, 0);
121            };
122            (CFA_REG_PLT, off)
123        }
124        _ => (CFA_REG_EXPRESSION, 0),
125    }
126}
127
128fn read_vdso(tgid: u32, start: u64, end: u64) -> Result<Vec<u8>, String> {
129    use std::io::{Read, Seek, SeekFrom};
130    if end <= start {
131        return Err("Invalid vDSO address range".to_string());
132    }
133    let mut f = std::fs::File::open(format!("/proc/{}/mem", tgid))
134        .map_err(|e| format!("Failed to open /proc/{}/mem: {}", tgid, e))?;
135    f.seek(SeekFrom::Start(start))
136        .map_err(|e| format!("Failed to seek to vDSO: {}", e))?;
137    let len = (end - start) as usize;
138    let mut buf = vec![0u8; len];
139    f.read_exact(&mut buf)
140        .map_err(|e| format!("Failed to read vDSO: {}", e))?;
141    Ok(buf)
142}
143
144/// Extracts the GNU build ID from an ELF binary
145///
146/// The build ID is a unique identifier embedded in the `.note.gnu.build-id` section
147/// of ELF binaries. It's typically a 20-byte SHA1 hash but can be other lengths.
148/// Returns None if no build ID is found.
149fn extract_build_id(data: &[u8]) -> Option<BuildId> {
150    let obj = object::File::parse(data).ok()?;
151    let section = obj.section_by_name(".note.gnu.build-id")?;
152    let note_data = section.data().ok()?;
153
154    // Parse ELF note format:
155    // struct {
156    //     u32 namesz;  // length of name (including null terminator)
157    //     u32 descsz;  // length of descriptor (the actual build ID)
158    //     u32 type;    // note type (3 = NT_GNU_BUILD_ID)
159    //     char name[namesz];   // "GNU\0" (aligned to 4 bytes)
160    //     char desc[descsz];   // the build ID bytes (aligned to 4 bytes)
161    // }
162
163    if note_data.len() < 16 {
164        return None;
165    }
166
167    let namesz =
168        u32::from_ne_bytes([note_data[0], note_data[1], note_data[2], note_data[3]]) as usize;
169    let descsz =
170        u32::from_ne_bytes([note_data[4], note_data[5], note_data[6], note_data[7]]) as usize;
171    let note_type = u32::from_ne_bytes([note_data[8], note_data[9], note_data[10], note_data[11]]);
172
173    // NT_GNU_BUILD_ID = 3
174    if note_type != 3 {
175        return None;
176    }
177
178    // Verify we have "GNU\0" name
179    if namesz < 4 || note_data.len() < 12 + namesz {
180        return None;
181    }
182
183    // Name is aligned to 4 bytes
184    let name_aligned = (namesz + 3) & !3;
185    let desc_offset = 12 + name_aligned;
186
187    if note_data.len() < desc_offset + descsz {
188        return None;
189    }
190
191    let build_id = note_data[desc_offset..desc_offset + descsz].to_vec();
192    Some(build_id)
193}
194
195pub fn generate_unwind_table_from_bytes(
196    data: &[u8],
197) -> Result<(Vec<UnwindEntry>, Option<BuildId>), String> {
198    use object::ObjectSegment;
199
200    let obj = object::File::parse(data).map_err(|e| format!("Failed to parse ELF: {}", e))?;
201
202    // Extract build ID first
203    let build_id = extract_build_id(data);
204
205    // Find the base virtual address (first PT_LOAD segment with file offset 0)
206    // For non-PIE executables this is typically 0x400000, for PIE/shared libs it's 0.
207    // We subtract this from .eh_frame PCs to make entries file-relative.
208    let base_vaddr = obj
209        .segments()
210        .find(|s| s.file_range().0 == 0)
211        .map(|s| s.address())
212        .unwrap_or(0);
213
214    let eh_frame_section = obj
215        .section_by_name(".eh_frame")
216        .ok_or_else(|| "No .eh_frame section found".to_string())?;
217
218    let eh_frame_data = eh_frame_section
219        .data()
220        .map_err(|e| format!("Failed to read .eh_frame data: {}", e))?;
221
222    let eh_frame_addr = eh_frame_section.address();
223
224    let eh_frame = EhFrame::new(eh_frame_data, NativeEndian);
225
226    let bases = BaseAddresses::default().set_eh_frame(eh_frame_addr);
227
228    let mut entries = Vec::new();
229    let mut ctx = gimli::UnwindContext::new();
230    let mut cies = HashMap::new();
231
232    let mut iter = eh_frame.entries(&bases);
233    while let Ok(Some(entry)) = iter.next() {
234        match entry {
235            gimli::CieOrFde::Cie(cie) => {
236                let offset = cie.offset();
237                cies.insert(offset, cie);
238            }
239            gimli::CieOrFde::Fde(partial_fde) => {
240                let fde = match partial_fde.parse(|_, bases, offset| {
241                    if let Some(cie) = cies.get(&offset.0) {
242                        Ok(cie.clone())
243                    } else {
244                        eh_frame.cie_from_offset(bases, offset)
245                    }
246                }) {
247                    Ok(fde) => fde,
248                    Err(_) => continue,
249                };
250
251                let mut table = match fde.rows(&eh_frame, &bases, &mut ctx) {
252                    Ok(table) => table,
253                    Err(_) => continue,
254                };
255
256                while let Ok(Some(row)) = table.next_row() {
257                    let pc = row.start_address();
258                    let cfa = row.cfa();
259
260                    let (cfa_type, cfa_offset) = match cfa {
261                        CfaRule::RegisterAndOffset { register, offset } => {
262                            let reg_type = if *register == X86_64_RSP {
263                                CFA_REG_RSP
264                            } else if *register == X86_64_RBP {
265                                CFA_REG_RBP
266                            } else {
267                                // Unsupported register for CFA
268                                continue;
269                            };
270                            // Skip entries with offsets that don't fit in i16
271                            let Ok(offset_i16) = i16::try_from(*offset) else {
272                                continue;
273                            };
274                            (reg_type, offset_i16)
275                        }
276                        CfaRule::Expression(expr) => classify_cfa_expression(expr, eh_frame_data),
277                    };
278
279                    // Skip DWARF expression-based CFA (too complex for eBPF)
280                    if cfa_type == CFA_REG_EXPRESSION {
281                        continue;
282                    }
283
284                    // Get return address rule — on x86_64 it's always CFA-8 for
285                    // normal frames. Signal frames use expression-based rules
286                    // (DW_OP_breg7+offset) which we handle specially.
287                    let ra_rule = row.register(X86_64_RA);
288                    let is_signal_frame = cfa_type == CFA_REG_DEREF_RSP;
289                    match ra_rule {
290                        RegisterRule::Offset(offset) if offset == -8 => {}
291                        // Signal frames: RA is an expression (breg7+168).
292                        // We hardcode the ucontext_t offsets in eBPF.
293                        RegisterRule::Expression(_) if is_signal_frame => {}
294                        RegisterRule::Undefined => continue,
295                        _ => continue,
296                    };
297
298                    // Get RBP rule (important for restoring frame pointer)
299                    let rbp_rule = row.register(X86_64_RBP);
300                    let (rbp_type, rbp_offset) = match rbp_rule {
301                        RegisterRule::Offset(offset) => {
302                            let Ok(offset_i16) = i16::try_from(offset) else {
303                                continue;
304                            };
305                            (REG_RULE_OFFSET, offset_i16)
306                        }
307                        RegisterRule::SameValue => (REG_RULE_SAME_VALUE, 0i16),
308                        RegisterRule::Undefined => (REG_RULE_UNDEFINED, 0i16),
309                        _ => (REG_RULE_UNDEFINED, 0i16),
310                    };
311
312                    let relative_pc = pc - base_vaddr;
313                    // Skip entries with PC > u32::MAX (shouldn't happen for
314                    // file-relative addresses, but be safe)
315                    let Ok(pc32) = u32::try_from(relative_pc) else {
316                        continue;
317                    };
318
319                    entries.push(UnwindEntry {
320                        pc: pc32,
321                        cfa_offset: cfa_offset as i16,
322                        rbp_offset,
323                        cfa_type,
324                        rbp_type,
325                        _pad: [0; 2],
326                    });
327                }
328            }
329        }
330    }
331
332    // Sort by PC address for binary search
333    entries.sort_by_key(|e| e.pc);
334
335    // Deduplicate consecutive entries with identical unwind rules.
336    // The binary search finds the last entry with pc <= target, so keeping
337    // only the first entry of a run with identical rules is correct.
338    let before = entries.len();
339    entries.dedup_by(|b, a| {
340        a.cfa_type == b.cfa_type
341            && a.cfa_offset == b.cfa_offset
342            && a.rbp_type == b.rbp_type
343            && a.rbp_offset == b.rbp_offset
344    });
345    let after = entries.len();
346    if before != after {
347        tracing::debug!(
348            "Dedup: {} -> {} entries ({:.1}% reduction)",
349            before,
350            after,
351            (1.0 - after as f64 / before as f64) * 100.0
352        );
353    }
354
355    Ok((entries, build_id))
356}
357
358/// Holds the unwind tables for all currently profiled processes
359pub struct DwarfUnwindManager {
360    /// Per-binary unwind tables: map from shard_id to the entries
361    pub binary_tables: HashMap<u8, Vec<UnwindEntry>>,
362    /// Per-process mapping information
363    pub proc_info: HashMap<u32, ProcInfo>,
364    /// Next shard_id to assign to a new binary
365    next_shard_id: u8,
366    /// Fast metadata-based cache for hot path lookups (stat-based)
367    metadata_cache: HashMap<FileMetadata, u8>, // metadata -> shard_id
368    /// Cache of parsed ELF binary shard IDs, keyed by build ID
369    /// Falls back to path-based caching for binaries without build IDs
370    binary_cache: HashMap<BuildId, u8>, // build_id -> shard_id
371    /// Fallback cache for binaries without build IDs (keyed by path)
372    path_cache: HashMap<std::path::PathBuf, u8>, // path -> shard_id
373}
374
375impl DwarfUnwindManager {
376    pub fn new() -> Self {
377        Self {
378            binary_tables: HashMap::new(),
379            proc_info: HashMap::new(),
380            next_shard_id: 0,
381            metadata_cache: HashMap::new(),
382            binary_cache: HashMap::new(),
383            path_cache: HashMap::new(),
384        }
385    }
386
387    /// Load unwind information for a process by scanning its memory mappings.
388    /// Returns Ok(()) if the process was loaded (or already loaded).
389    pub fn load_process(&mut self, tgid: u32) -> Result<(), String> {
390        if self.proc_info.contains_key(&tgid) {
391            return Ok(());
392        }
393        // Wait for the dynamic linker to finish mapping shared libraries.
394        // After fork+exec, /proc/PID/maps may not yet reflect all mappings.
395        // Poll until the mapping count stabilizes (typically <100ms).
396        let mut prev_count = 0usize;
397        for _ in 0..20 {
398            let count = Self::count_exec_maps(tgid);
399            if count > 0 && count == prev_count {
400                break;
401            }
402            prev_count = count;
403            std::thread::sleep(std::time::Duration::from_millis(10));
404        }
405        self.scan_and_update(tgid)
406    }
407
408    /// Rescan a process's memory mappings and load any new ones.
409    /// Returns the list of new shard IDs added (for incremental eBPF updates).
410    pub fn refresh_process(&mut self, tgid: u32) -> Result<Vec<u8>, String> {
411        let old_shard_ids: Vec<u8> = self.binary_tables.keys().copied().collect();
412        self.scan_and_update(tgid)?;
413        let new_shard_ids: Vec<u8> = self
414            .binary_tables
415            .keys()
416            .copied()
417            .filter(|id| !old_shard_ids.contains(id))
418            .collect();
419        Ok(new_shard_ids)
420    }
421
422    /// Count executable memory mappings for a process (fast, no file I/O).
423    fn count_exec_maps(tgid: u32) -> usize {
424        let Ok(process) = Process::new(tgid as i32) else {
425            return 0;
426        };
427        let Ok(maps) = process.maps() else {
428            return 0;
429        };
430        use procfs::process::MMPermissions;
431        maps.iter()
432            .filter(|m| {
433                m.perms.contains(MMPermissions::EXECUTE)
434                    && m.perms.contains(MMPermissions::READ)
435                    && matches!(m.pathname, MMapPath::Path(_) | MMapPath::Vdso)
436            })
437            .count()
438    }
439
440    fn scan_and_update(&mut self, tgid: u32) -> Result<(), String> {
441        let process = Process::new(tgid as i32)
442            .map_err(|e| format!("Failed to open process {}: {}", tgid, e))?;
443
444        let maps = process
445            .maps()
446            .map_err(|e| format!("Failed to read maps for {}: {}", tgid, e))?;
447
448        // Collect existing mapping addresses so we can skip them
449        let existing = self.proc_info.get(&tgid);
450        let existing_ranges: Vec<(u64, u64)> = existing
451            .map(|pi| {
452                (0..pi.mapping_count as usize)
453                    .map(|i| (pi.mappings[i].begin, pi.mappings[i].end))
454                    .collect()
455            })
456            .unwrap_or_default();
457
458        let mut proc_info = existing.copied().unwrap_or(ProcInfo {
459            mapping_count: 0,
460            _pad: 0,
461            mappings: [ExecMapping {
462                begin: 0,
463                end: 0,
464                load_bias: 0,
465                shard_id: SHARD_NONE,
466                _pad1: [0; 3],
467                table_count: 0,
468            }; MAX_PROC_MAPS],
469        });
470
471        let root_path = format!("/proc/{}/root", tgid);
472
473        for map in maps.iter() {
474            if proc_info.mapping_count as usize >= MAX_PROC_MAPS {
475                break;
476            }
477
478            let perms = &map.perms;
479            use procfs::process::MMPermissions;
480            if !perms.contains(MMPermissions::EXECUTE) || !perms.contains(MMPermissions::READ) {
481                continue;
482            }
483
484            let file_path = match &map.pathname {
485                MMapPath::Path(p) => p.to_path_buf(),
486                MMapPath::Vdso => std::path::PathBuf::from("[vdso]"),
487                _ => continue,
488            };
489
490            let start_addr = map.address.0;
491            let end_addr = map.address.1;
492            let file_offset = map.offset;
493            let is_vdso = matches!(&map.pathname, MMapPath::Vdso);
494
495            // Skip mappings we already have
496            if existing_ranges
497                .iter()
498                .any(|&(b, e)| b == start_addr && e == end_addr)
499            {
500                continue;
501            }
502
503            let resolved_path = if is_vdso {
504                file_path.clone()
505            } else {
506                let mut p = std::path::PathBuf::from(&root_path);
507                p.push(file_path.strip_prefix("/").unwrap_or(&file_path));
508                if p.exists() {
509                    p
510                } else {
511                    file_path.clone()
512                }
513            };
514
515            if !is_vdso && !resolved_path.exists() {
516                continue;
517            }
518
519            let load_bias = start_addr.wrapping_sub(file_offset);
520
521            // Two-tier cache lookup:
522            // 1. Hot path: stat-based metadata lookup (single syscall, no file read)
523            // 2. Cold path: full file read + build-ID extraction (cache miss only)
524            let (shard_id, table_count) = {
525                // Try fast metadata-based cache lookup first (for non-vdso files)
526                let metadata_cache_hit = if !is_vdso {
527                    FileMetadata::from_path(&resolved_path)
528                        .ok()
529                        .and_then(|meta| self.metadata_cache.get(&meta).copied())
530                } else {
531                    None
532                };
533
534                if let Some(sid) = metadata_cache_hit {
535                    // Hot path: metadata cache hit - no file read needed!
536                    let tc = self
537                        .binary_tables
538                        .get(&sid)
539                        .map(|t| t.len() as u32)
540                        .unwrap_or(0);
541                    (sid, tc)
542                } else {
543                    // Cold path: metadata cache miss - need to read binary
544                    let binary_data = if is_vdso {
545                        read_vdso(tgid, start_addr, end_addr).ok()
546                    } else {
547                        fs::read(&resolved_path).ok()
548                    };
549
550                    let cache_hit = if let Some(ref data) = binary_data {
551                        // Try build-ID based cache lookup
552                        if let Some(build_id) = extract_build_id(data) {
553                            self.binary_cache.get(&build_id).copied()
554                        } else {
555                            // Fall back to path-based cache for binaries without build ID
556                            self.path_cache.get(&resolved_path).copied()
557                        }
558                    } else {
559                        None
560                    };
561
562                    if let Some(sid) = cache_hit {
563                        // Build-ID or path cache hit - store metadata mapping for next time
564                        if !is_vdso {
565                            if let Ok(meta) = FileMetadata::from_path(&resolved_path) {
566                                self.metadata_cache.insert(meta, sid);
567                            }
568                        }
569                        let tc = self
570                            .binary_tables
571                            .get(&sid)
572                            .map(|t| t.len() as u32)
573                            .unwrap_or(0);
574                        (sid, tc)
575                    } else {
576                        // Cache miss - need to parse the binary
577                        let (unwind_entries, build_id_opt) = if let Some(data) = binary_data {
578                            match generate_unwind_table_from_bytes(&data) {
579                                Ok(result) => result,
580                                Err(e) => {
581                                    let name = if is_vdso {
582                                        "[vdso]".to_string()
583                                    } else {
584                                        resolved_path.display().to_string()
585                                    };
586                                    tracing::debug!("Skipping {} for pid {}: {}", name, tgid, e);
587                                    continue;
588                                }
589                            }
590                        } else {
591                            let name = if is_vdso {
592                                "[vdso]".to_string()
593                            } else {
594                                resolved_path.display().to_string()
595                            };
596                            tracing::debug!("Failed to read binary {} for pid {}", name, tgid);
597                            continue;
598                        };
599
600                        if unwind_entries.is_empty() {
601                            continue;
602                        }
603
604                        let tc = match u32::try_from(unwind_entries.len()) {
605                            Ok(v) => v,
606                            Err(_) => {
607                                let name = if is_vdso {
608                                    "[vdso]".to_string()
609                                } else {
610                                    resolved_path.display().to_string()
611                                };
612                                tracing::warn!(
613                                    "Unwind table too large for {}: {} entries",
614                                    name,
615                                    unwind_entries.len(),
616                                );
617                                continue;
618                            }
619                        };
620
621                        if tc > MAX_SHARD_ENTRIES {
622                            tracing::warn!(
623                                "Binary unwind table too large: {} entries (max {} per shard), skipping",
624                                tc,
625                                MAX_SHARD_ENTRIES,
626                            );
627                            continue;
628                        }
629
630                        if self.next_shard_id as usize >= MAX_UNWIND_SHARDS {
631                            tracing::warn!(
632                                "All {} shard slots used, skipping remaining binaries for pid {}",
633                                MAX_UNWIND_SHARDS,
634                                tgid,
635                            );
636                            break;
637                        }
638
639                        let sid = self.next_shard_id;
640                        self.next_shard_id += 1;
641
642                        self.binary_tables.insert(sid, unwind_entries);
643
644                        // Cache using build ID if available, otherwise use path
645                        if let Some(build_id) = build_id_opt {
646                            self.binary_cache.insert(build_id, sid);
647                        } else {
648                            self.path_cache.insert(resolved_path.clone(), sid);
649                        }
650
651                        // Also cache by metadata for fast future lookups
652                        if !is_vdso {
653                            if let Ok(meta) = FileMetadata::from_path(&resolved_path) {
654                                self.metadata_cache.insert(meta, sid);
655                            }
656                        }
657
658                        (sid, tc)
659                    }
660                }
661            };
662
663            let idx = proc_info.mapping_count as usize;
664            proc_info.mappings[idx] = ExecMapping {
665                begin: start_addr,
666                end: end_addr,
667                load_bias,
668                shard_id,
669                _pad1: [0; 3],
670                table_count,
671            };
672            proc_info.mapping_count += 1;
673        }
674
675        self.proc_info.insert(tgid, proc_info);
676
677        Ok(())
678    }
679
680    /// Returns the total number of table entries across all binaries
681    pub fn total_entries(&self) -> usize {
682        self.binary_tables.values().map(|t| t.len()).sum()
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689
690    #[test]
691    fn test_generate_unwind_table_self() {
692        // Parse the current test binary's .eh_frame
693        let exe = std::env::current_exe().unwrap();
694        let (entries, build_id) = generate_unwind_table(&exe).unwrap();
695        assert!(
696            !entries.is_empty(),
697            "Expected non-empty unwind table for test binary"
698        );
699
700        // Check that build ID was extracted
701        assert!(build_id.is_some(), "Expected build ID for test binary");
702
703        // Entries should be sorted by PC
704        for w in entries.windows(2) {
705            assert!(
706                w[0].pc <= w[1].pc,
707                "Unwind entries not sorted: {} > {}",
708                w[0].pc,
709                w[1].pc
710            );
711        }
712
713        // All entries should have valid CFA types
714        for entry in &entries {
715            assert!(
716                matches!(
717                    entry.cfa_type,
718                    CFA_REG_RSP | CFA_REG_RBP | CFA_REG_PLT | CFA_REG_DEREF_RSP
719                ),
720                "Unexpected CFA type: {}",
721                entry.cfa_type
722            );
723            // CFA offset should be non-zero for typical x86_64 code
724            // (at minimum RSP+8 for the return address push)
725        }
726    }
727
728    #[test]
729    fn test_generate_unwind_table_missing_file() {
730        let result = generate_unwind_table(Path::new("/nonexistent/binary"));
731        assert!(result.is_err());
732    }
733
734    #[test]
735    fn test_generate_unwind_table_invalid_elf() {
736        let result = generate_unwind_table_from_bytes(b"not an elf file");
737        assert!(result.is_err());
738    }
739
740    #[test]
741    fn test_dwarf_manager_new() {
742        let manager = DwarfUnwindManager::new();
743        assert_eq!(manager.total_entries(), 0);
744        assert!(manager.proc_info.is_empty());
745    }
746
747    #[test]
748    fn test_unwind_entry_sizes() {
749        // Verify the struct sizes are what we expect for eBPF compatibility
750        assert_eq!(std::mem::size_of::<UnwindEntry>(), UnwindEntry::STRUCT_SIZE);
751        // Should be 12 bytes (compact format)
752        assert_eq!(std::mem::size_of::<UnwindEntry>(), 12);
753    }
754
755    #[test]
756    fn test_unwind_table_return_address_convention() {
757        // On x86_64, return address is always at CFA-8.
758        // The compact format hardcodes this, so we just verify the entries
759        // are generated (RA rule filtering happens during generation).
760        let exe = std::env::current_exe().unwrap();
761        let (entries, _) = generate_unwind_table(&exe).unwrap();
762        assert!(!entries.is_empty(), "Expected non-empty unwind table");
763    }
764
765    #[test]
766    fn test_load_current_process() {
767        let pid = std::process::id();
768        let mut manager = DwarfUnwindManager::new();
769        let result = manager.load_process(pid);
770        assert!(
771            result.is_ok(),
772            "Failed to load current process: {:?}",
773            result
774        );
775        assert!(
776            manager.total_entries() > 0,
777            "Expected non-empty unwind table for current process"
778        );
779        assert!(
780            manager.proc_info.contains_key(&pid),
781            "Expected proc_info entry for current process"
782        );
783        let info = &manager.proc_info[&pid];
784        assert!(
785            info.mapping_count > 0,
786            "Expected at least one executable mapping"
787        );
788    }
789
790    #[test]
791    fn test_libc_unwind_table() {
792        // Parse libc's .eh_frame to verify we can handle shared libraries
793        let libc_paths = [
794            "/lib/x86_64-linux-gnu/libc.so.6",
795            "/usr/lib/x86_64-linux-gnu/libc.so.6",
796            "/lib64/libc.so.6",
797        ];
798
799        let libc_path = libc_paths.iter().find(|p| Path::new(p).exists());
800        if let Some(path) = libc_path {
801            let (entries, build_id) = generate_unwind_table(Path::new(path)).unwrap();
802            assert!(
803                !entries.is_empty(),
804                "Expected non-empty unwind table for libc"
805            );
806            // libc should have many unwind entries
807            assert!(
808                entries.len() > 100,
809                "Expected >100 entries for libc, got {}",
810                entries.len()
811            );
812            // libc should have a build ID
813            assert!(build_id.is_some(), "Expected build ID for libc");
814        }
815    }
816
817    #[test]
818    fn test_build_id_extraction() {
819        // Test that build ID extraction works on the test binary
820        let exe = std::env::current_exe().unwrap();
821        let data = fs::read(&exe).unwrap();
822        let build_id = extract_build_id(&data);
823        assert!(build_id.is_some(), "Expected build ID in test binary");
824
825        // Build IDs are typically 20 bytes (SHA1) but can vary
826        let id = build_id.unwrap();
827        assert!(!id.is_empty(), "Build ID should not be empty");
828        assert!(id.len() >= 8, "Build ID should be at least 8 bytes");
829    }
830
831    #[test]
832    fn test_build_id_caching() {
833        // Test that the same library loaded by multiple "processes" uses cached entries
834        let mut manager = DwarfUnwindManager::new();
835        let pid = std::process::id();
836
837        // Load current process
838        let result = manager.load_process(pid);
839        assert!(result.is_ok(), "Failed to load process: {:?}", result);
840
841        let initial_cache_size = manager.binary_cache.len() + manager.path_cache.len();
842        let initial_table_size = manager.total_entries();
843
844        assert!(initial_cache_size > 0, "Expected some cached binaries");
845        assert!(initial_table_size > 0, "Expected non-empty unwind table");
846
847        // In a real scenario with multiple processes sharing libraries,
848        // we would see cache hits here. For this test, we just verify
849        // the caching mechanism is set up correctly.
850    }
851
852    #[test]
853    fn test_metadata_based_caching() {
854        // Test that metadata cache is used for fast lookups
855        let mut manager = DwarfUnwindManager::new();
856        let pid = std::process::id();
857
858        // Load current process - this should populate metadata cache
859        let result = manager.load_process(pid);
860        assert!(result.is_ok(), "Failed to load process: {:?}", result);
861
862        let metadata_cache_size = manager.metadata_cache.len();
863        assert!(
864            metadata_cache_size > 0,
865            "Expected metadata cache to be populated"
866        );
867
868        // Refresh the same process - should use metadata cache for fast lookups
869        let new_shards = manager.refresh_process(pid).unwrap();
870
871        // Since the binaries haven't changed, we shouldn't have new shards
872        assert_eq!(new_shards.len(), 0, "Expected no new shards on refresh");
873
874        // Metadata cache size should remain the same or grow slightly
875        let new_metadata_cache_size = manager.metadata_cache.len();
876        assert!(
877            new_metadata_cache_size >= metadata_cache_size,
878            "Metadata cache should not shrink"
879        );
880    }
881}