Skip to main content

padlock_source/
lib.rs

1// padlock-source/src/lib.rs
2
3pub mod concurrency;
4pub mod fixgen;
5pub mod frontends;
6
7use std::collections::HashMap;
8use std::path::Path;
9
10use padlock_core::arch::ArchConfig;
11use padlock_core::findings::SkippedStruct;
12use padlock_core::ir::{StructLayout, TypeInfo};
13
14/// C++ standard library implementation variant.
15///
16/// Affects hardcoded sizes of types like `std::string`, `std::mutex`, etc.
17/// The default is `LibStdCpp` (GCC / Linux / glibc).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum CppStdlib {
20    /// GCC libstdc++ (Linux/glibc default). `std::string` = 32B.
21    #[default]
22    LibStdCpp,
23    /// LLVM libc++ (Clang/macOS/Android). `std::string` = 24B.
24    LibCpp,
25    /// Microsoft MSVC STL (Windows). `std::string` = 32B (SSO = 16 chars).
26    Msvc,
27}
28
29/// Set the C++ stdlib variant used for type-size lookups during source analysis.
30///
31/// This is a thread-local setting; it takes effect for all subsequent calls to
32/// `parse_source` / `parse_source_str` on the current thread.  The default is
33/// `CppStdlib::LibStdCpp`.  Call this from the CLI before invoking analysis.
34pub fn set_cpp_stdlib(stdlib: CppStdlib) {
35    frontends::c_cpp::set_stdlib(stdlib);
36}
37
38// ── skipped-struct side channel ───────────────────────────────────────────────
39//
40// Frontends call `record_skipped` (via `crate::record_skipped`) when they skip
41// a type they cannot accurately size (generics, templates, etc.).  `parse_source`
42// drains the buffer after each file so callers receive structured skip data
43// alongside the layouts.
44
45thread_local! {
46    static SKIPPED_COLLECTOR: std::cell::RefCell<Vec<SkippedStruct>> =
47        const { std::cell::RefCell::new(Vec::new()) };
48}
49
50/// Record a skipped struct/type into the per-thread buffer.
51///
52/// Called by language frontends when they encounter a type they cannot size
53/// (e.g. generic/template types).  The buffer is drained by `parse_source`.
54pub fn record_skipped(name: &str, reason: &str) {
55    SKIPPED_COLLECTOR.with(|c| {
56        c.borrow_mut().push(SkippedStruct {
57            name: name.to_string(),
58            reason: reason.to_string(),
59            source_file: None,
60        });
61    });
62}
63
64fn take_skipped() -> Vec<SkippedStruct> {
65    SKIPPED_COLLECTOR.with(|c| c.take())
66}
67
68/// Output from a single source-file parse: the extracted layouts plus any
69/// types that were skipped (e.g. generics whose layout is unknown).
70pub struct ParseOutput {
71    pub layouts: Vec<StructLayout>,
72    pub skipped: Vec<SkippedStruct>,
73}
74
75#[derive(Debug, Clone, PartialEq)]
76pub enum SourceLanguage {
77    C,
78    Cpp,
79    Rust,
80    Go,
81    Zig,
82}
83
84/// Detect language from file extension.
85pub fn detect_language(path: &Path) -> Option<SourceLanguage> {
86    match path.extension().and_then(|e| e.to_str()) {
87        Some("c") | Some("h") => Some(SourceLanguage::C),
88        Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") => Some(SourceLanguage::Cpp),
89        Some("rs") => Some(SourceLanguage::Rust),
90        Some("go") => Some(SourceLanguage::Go),
91        Some("zig") => Some(SourceLanguage::Zig),
92        _ => None,
93    }
94}
95
96/// Parse a source file and return layouts plus any skipped types.
97pub fn parse_source(path: &Path, arch: &'static ArchConfig) -> anyhow::Result<ParseOutput> {
98    let lang = detect_language(path)
99        .ok_or_else(|| anyhow::anyhow!("unsupported file type: {}", path.display()))?;
100    let source = std::fs::read_to_string(path)?;
101    // Clear any leftover thread-local state from previous direct parse_source_str calls.
102    let _ = take_skipped();
103    let mut layouts = parse_source_str(&source, &lang, arch)?;
104    let mut skipped = take_skipped();
105    let file_str = path.to_string_lossy().into_owned();
106    for layout in &mut layouts {
107        layout.source_file = Some(file_str.clone());
108    }
109    for s in &mut skipped {
110        s.source_file = Some(file_str.clone());
111    }
112    Ok(ParseOutput { layouts, skipped })
113}
114
115/// Parse source text directly (useful for tests and piped input).
116pub fn parse_source_str(
117    source: &str,
118    lang: &SourceLanguage,
119    arch: &'static ArchConfig,
120) -> anyhow::Result<Vec<StructLayout>> {
121    let mut layouts = match lang {
122        SourceLanguage::C => frontends::c_cpp::parse_c(source, arch)?,
123        SourceLanguage::Cpp => frontends::c_cpp::parse_cpp(source, arch)?,
124        SourceLanguage::Rust => frontends::rust::parse_rust(source, arch)?,
125        SourceLanguage::Go => frontends::go::parse_go(source, arch)?,
126        SourceLanguage::Zig => frontends::zig::parse_zig(source, arch)?,
127    };
128
129    // Resolve fields whose type names match other structs in this file.
130    // This makes nested struct sizes accurate (instead of defaulting to pointer size).
131    resolve_nested_structs(&mut layouts);
132
133    // Annotate concurrency patterns
134    for layout in &mut layouts {
135        concurrency::annotate_concurrency(layout, lang);
136    }
137
138    // Remove structs explicitly opted out via `// padlock:ignore`
139    layouts.retain(|layout| !is_padlock_ignored(source, &layout.name));
140
141    Ok(layouts)
142}
143
144// ── nested struct resolution ──────────────────────────────────────────────────
145
146/// Returns true if `name` is a well-known primitive type name in any supported
147/// language. These must never be shadowed by a user-defined struct name.
148fn is_known_primitive(name: &str) -> bool {
149    matches!(
150        name,
151        // Rust primitives
152        "bool" | "u8" | "i8" | "u16" | "i16" | "u32" | "i32" | "f32" | "u64" | "i64" | "f64"
153            | "u128" | "i128" | "usize" | "isize" | "char" | "str"
154            // C/C++ primitives
155            | "int" | "long" | "short" | "float" | "double" | "void"
156            | "int8_t" | "uint8_t" | "int16_t" | "uint16_t" | "int32_t" | "uint32_t"
157            | "int64_t" | "uint64_t" | "size_t" | "ssize_t" | "ptrdiff_t"
158            | "intptr_t" | "uintptr_t" | "_Bool"
159            // Go primitives
160            | "int8" | "uint8" | "byte" | "int16" | "uint16" | "int32" | "uint32"
161            | "int64" | "uint64" | "float32" | "float64" | "complex64" | "complex128"
162            | "rune" | "string" | "error"
163            // SIMD
164            | "__m64" | "__m128" | "__m128d" | "__m128i"
165            | "__m256" | "__m256d" | "__m256i"
166            | "__m512" | "__m512d" | "__m512i"
167    )
168}
169
170/// Resolve fields whose type name matches another parsed struct.
171///
172/// Runs in a loop until stable to handle transitive nesting (struct A contains
173/// B which contains C). In practice, 2–3 iterations suffice for typical code.
174fn resolve_nested_structs(layouts: &mut [StructLayout]) {
175    loop {
176        // Build name → (total_size, align) from whatever we have so far.
177        let known: HashMap<String, (usize, usize)> = layouts
178            .iter()
179            .map(|l| (l.name.clone(), (l.total_size, l.align)))
180            .collect();
181
182        let mut changed_any = false;
183
184        for layout in layouts.iter_mut() {
185            let mut changed = false;
186
187            for field in layout.fields.iter_mut() {
188                // Extract the type name from Primitive or Opaque variants.
189                // Struct/Pointer/Array variants are already correctly sized.
190                let type_name: String = match &field.ty {
191                    TypeInfo::Primitive { name, .. } | TypeInfo::Opaque { name, .. } => {
192                        name.clone()
193                    }
194                    _ => continue,
195                };
196
197                // Never shadow built-in primitives.
198                if is_known_primitive(&type_name) {
199                    continue;
200                }
201
202                // Don't resolve a struct to itself (circular).
203                if type_name == layout.name {
204                    continue;
205                }
206
207                if let Some(&(struct_size, struct_align)) = known.get(&type_name) {
208                    // Only update if the size would change — avoids infinite loops
209                    // for pointer-sized structs that already have the right size.
210                    if field.size == struct_size && field.align == struct_align {
211                        continue;
212                    }
213                    let eff_align = if layout.is_packed { 1 } else { struct_align };
214                    field.ty = TypeInfo::Opaque {
215                        name: type_name,
216                        size: struct_size,
217                        align: struct_align,
218                    };
219                    field.size = struct_size;
220                    field.align = eff_align;
221                    changed = true;
222                }
223            }
224
225            if changed {
226                resimulate_layout(layout);
227                changed_any = true;
228            }
229        }
230
231        if !changed_any {
232            break;
233        }
234    }
235
236    // Post-pass: any field still TypeInfo::Opaque whose type name is not among
237    // the parsed structs was not resolved — its size is a pointer-sized fallback.
238    // Flag these so output layers can warn the user.
239    let known_names: std::collections::HashSet<String> =
240        layouts.iter().map(|l| l.name.clone()).collect();
241
242    for layout in layouts.iter_mut() {
243        for field in layout.fields.iter() {
244            if let TypeInfo::Opaque {
245                name: type_name, ..
246            } = &field.ty
247            {
248                if is_known_primitive(type_name)
249                    || type_name == &layout.name
250                    || known_names.contains(type_name.as_str() as &str)
251                {
252                    continue;
253                }
254                if !layout.uncertain_fields.contains(&field.name) {
255                    layout.uncertain_fields.push(field.name.clone());
256                }
257            }
258        }
259    }
260}
261
262/// Re-simulate field offsets and total_size after field sizes have been updated.
263fn resimulate_layout(layout: &mut StructLayout) {
264    if layout.is_union {
265        for field in layout.fields.iter_mut() {
266            field.offset = 0;
267        }
268        let max_size = layout.fields.iter().map(|f| f.size).max().unwrap_or(0);
269        let max_align = layout.fields.iter().map(|f| f.align).max().unwrap_or(1);
270        layout.total_size = if max_align > 0 {
271            max_size.next_multiple_of(max_align)
272        } else {
273            max_size
274        };
275        layout.align = max_align;
276        return;
277    }
278
279    let packed = layout.is_packed;
280    let mut offset = 0usize;
281    let mut struct_align = 1usize;
282
283    for field in layout.fields.iter_mut() {
284        let eff_align = if packed { 1 } else { field.align };
285        if eff_align > 0 {
286            offset = offset.next_multiple_of(eff_align);
287        }
288        field.offset = offset;
289        offset += field.size;
290        struct_align = struct_align.max(eff_align);
291    }
292
293    if !packed && struct_align > 0 {
294        offset = offset.next_multiple_of(struct_align);
295    }
296
297    layout.total_size = offset;
298    layout.align = struct_align;
299}
300
301/// Returns `true` if a `// padlock:ignore` comment appears on the line
302/// immediately before (or inline on the same line as) the struct/union/type
303/// declaration for `struct_name`.
304///
305/// This allows callers to suppress analysis for a specific struct by writing:
306/// ```c
307/// // padlock:ignore
308/// struct MySpecialLayout { ... };
309/// ```
310fn is_padlock_ignored(source: &str, struct_name: &str) -> bool {
311    // Keywords that introduce named type definitions across all supported languages
312    for keyword in &["struct", "union", "type"] {
313        let needle = format!("{keyword} {struct_name}");
314        let mut search = 0usize;
315        while let Some(rel) = source[search..].find(&needle) {
316            let abs = search + rel;
317            // Ensure the character after the name is a word boundary (not part of a longer name)
318            let after_name = abs + needle.len();
319            let is_boundary = source[after_name..]
320                .chars()
321                .next()
322                .is_none_or(|c| !c.is_alphanumeric() && c != '_');
323            if is_boundary {
324                let line_start = source[..abs].rfind('\n').map(|i| i + 1).unwrap_or(0);
325                // Check the line containing the struct keyword for an inline annotation
326                let line_end = source[abs..]
327                    .find('\n')
328                    .map(|i| abs + i)
329                    .unwrap_or(source.len());
330                if source[line_start..line_end].contains("padlock:ignore") {
331                    return true;
332                }
333                // Check the immediately preceding line for an annotation comment.
334                // Only accept it if the preceding line is a pure comment (starts with `//`
335                // after trimming), so that an inline annotation on a prior struct's closing
336                // line doesn't accidentally suppress the following struct.
337                if line_start > 0 {
338                    let prev_end = line_start - 1;
339                    let prev_start = source[..prev_end].rfind('\n').map(|i| i + 1).unwrap_or(0);
340                    let prev_trimmed = source[prev_start..prev_end].trim();
341                    if prev_trimmed.starts_with("//") && prev_trimmed.contains("padlock:ignore") {
342                        return true;
343                    }
344                }
345            }
346            search = abs + 1;
347        }
348    }
349    false
350}
351
352// ── tests ─────────────────────────────────────────────────────────────────────
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use padlock_core::arch::X86_64_SYSV;
358
359    #[test]
360    fn detect_c_extensions() {
361        assert_eq!(detect_language(Path::new("foo.c")), Some(SourceLanguage::C));
362        assert_eq!(detect_language(Path::new("foo.h")), Some(SourceLanguage::C));
363    }
364
365    #[test]
366    fn detect_cpp_extensions() {
367        assert_eq!(
368            detect_language(Path::new("foo.cpp")),
369            Some(SourceLanguage::Cpp)
370        );
371        assert_eq!(
372            detect_language(Path::new("foo.cc")),
373            Some(SourceLanguage::Cpp)
374        );
375        assert_eq!(
376            detect_language(Path::new("foo.hpp")),
377            Some(SourceLanguage::Cpp)
378        );
379    }
380
381    #[test]
382    fn detect_rust_extension() {
383        assert_eq!(
384            detect_language(Path::new("foo.rs")),
385            Some(SourceLanguage::Rust)
386        );
387    }
388
389    #[test]
390    fn detect_go_extension() {
391        assert_eq!(
392            detect_language(Path::new("foo.go")),
393            Some(SourceLanguage::Go)
394        );
395    }
396
397    #[test]
398    fn detect_zig_extension() {
399        assert_eq!(
400            detect_language(Path::new("foo.zig")),
401            Some(SourceLanguage::Zig)
402        );
403    }
404
405    #[test]
406    fn detect_unknown_is_none() {
407        assert_eq!(detect_language(Path::new("foo.py")), None);
408        assert_eq!(detect_language(Path::new("foo")), None);
409    }
410
411    #[test]
412    fn parse_source_str_c_roundtrip() {
413        let src = "struct Point { int x; int y; };";
414        let layouts = parse_source_str(src, &SourceLanguage::C, &X86_64_SYSV).unwrap();
415        assert_eq!(layouts.len(), 1);
416        assert_eq!(layouts[0].name, "Point");
417    }
418
419    #[test]
420    fn parse_source_str_rust_roundtrip() {
421        let src = "struct Foo { x: u32, y: u64 }";
422        let layouts = parse_source_str(src, &SourceLanguage::Rust, &X86_64_SYSV).unwrap();
423        assert_eq!(layouts.len(), 1);
424        assert_eq!(layouts[0].name, "Foo");
425    }
426
427    #[test]
428    fn padlock_ignore_suppresses_c_struct() {
429        let src = "// padlock:ignore\nstruct Hidden { int x; int y; };\nstruct Visible { int a; };";
430        let layouts = parse_source_str(src, &SourceLanguage::C, &X86_64_SYSV).unwrap();
431        assert_eq!(layouts.len(), 1);
432        assert_eq!(layouts[0].name, "Visible");
433    }
434
435    #[test]
436    fn padlock_ignore_inline_suppresses_c_struct() {
437        // Inline annotation on the struct's own line suppresses it, but must NOT
438        // suppress the struct that follows (the next struct's preceding line is a
439        // code line with a trailing comment, not a pure `//` comment line).
440        let src = "struct Hidden { int x; }; // padlock:ignore\nstruct Visible { int a; };";
441        let layouts = parse_source_str(src, &SourceLanguage::C, &X86_64_SYSV).unwrap();
442        assert_eq!(layouts.len(), 1, "only Visible should remain");
443        assert_eq!(layouts[0].name, "Visible");
444    }
445
446    #[test]
447    fn padlock_ignore_suppresses_rust_struct() {
448        let src = "// padlock:ignore\nstruct Hidden { x: u32 }\nstruct Visible { a: u32 }";
449        let layouts = parse_source_str(src, &SourceLanguage::Rust, &X86_64_SYSV).unwrap();
450        assert_eq!(layouts.len(), 1);
451        assert_eq!(layouts[0].name, "Visible");
452    }
453
454    #[test]
455    fn padlock_ignore_without_annotation_keeps_struct() {
456        let src = "struct Visible { int x; int y; };";
457        let layouts = parse_source_str(src, &SourceLanguage::C, &X86_64_SYSV).unwrap();
458        assert_eq!(layouts.len(), 1);
459        assert_eq!(layouts[0].name, "Visible");
460    }
461
462    // ── nested struct resolution ───────────────────────────────────────────────
463
464    #[test]
465    fn nested_rust_struct_size_resolved() {
466        // Inner is 8 bytes. Outer has a field of type Inner.
467        // Without resolution, Inner's field size would be pointer_size (8) — coincidentally
468        // correct here, but offset placement still validates the pass runs.
469        let src = "struct Inner { x: u64 }\nstruct Outer { a: u8, b: Inner }";
470        let layouts = parse_source_str(src, &SourceLanguage::Rust, &X86_64_SYSV).unwrap();
471        let outer = layouts.iter().find(|l| l.name == "Outer").unwrap();
472        let b = outer.fields.iter().find(|f| f.name == "b").unwrap();
473        assert_eq!(b.size, 8, "Inner is 8 bytes");
474        assert_eq!(b.align, 8, "Inner aligns to 8");
475        // Outer: u8 at 0, [7 pad], Inner at 8 → total 16
476        assert_eq!(outer.total_size, 16);
477    }
478
479    #[test]
480    fn nested_rust_struct_non_pointer_size_resolved() {
481        // Point is 8 bytes (two i32). Line contains two Points — should be 16 bytes, not
482        // 2 * pointer_size = 16 (same here, but alignment is distinct).
483        let src = "struct Point { x: i32, y: i32 }\nstruct Line { a: Point, b: Point }";
484        let layouts = parse_source_str(src, &SourceLanguage::Rust, &X86_64_SYSV).unwrap();
485        let line = layouts.iter().find(|l| l.name == "Line").unwrap();
486        assert_eq!(line.total_size, 16);
487        assert_eq!(line.fields[0].size, 8);
488        assert_eq!(line.fields[1].size, 8);
489        assert_eq!(line.fields[1].offset, 8);
490    }
491
492    #[test]
493    fn nested_rust_struct_large_inner_triggers_padding() {
494        // SmallHeader: bool (1 byte). BigPayload: [u64; 4] = 32 bytes.
495        // Wrapper { flag: SmallHeader, data: BigPayload }
496        // Without resolution: SmallHeader is pointer-sized (8), total 8+32=40 → wrong.
497        // With resolution: SmallHeader is 1 byte, then 7 pad, then BigPayload at 8 → total 40.
498        // Actually u64 array: [u64;4] parsed as Array of 4 u64 = 32 bytes, align 8.
499        let src = "struct SmallHeader { flag: bool }\nstruct Wrapper { h: SmallHeader, data: u64 }";
500        let layouts = parse_source_str(src, &SourceLanguage::Rust, &X86_64_SYSV).unwrap();
501        let wrapper = layouts.iter().find(|l| l.name == "Wrapper").unwrap();
502        let h = wrapper.fields.iter().find(|f| f.name == "h").unwrap();
503        // SmallHeader has total_size=1, align=1
504        assert_eq!(h.size, 1, "SmallHeader resolved to 1 byte");
505        assert_eq!(h.align, 1);
506        // data (u64, align 8) should be at offset 8 (7 bytes padding after SmallHeader)
507        let data = wrapper.fields.iter().find(|f| f.name == "data").unwrap();
508        assert_eq!(data.offset, 8);
509        assert_eq!(wrapper.total_size, 16);
510    }
511
512    #[test]
513    fn nested_c_struct_resolved() {
514        let src =
515            "struct Vec2 { float x; float y; };\nstruct Rect { struct Vec2 tl; struct Vec2 br; };";
516        let layouts = parse_source_str(src, &SourceLanguage::C, &X86_64_SYSV).unwrap();
517        let rect = layouts.iter().find(|l| l.name == "Rect").unwrap();
518        // Each Vec2 is 8 bytes (two floats). Rect = 16 bytes, no padding.
519        assert_eq!(rect.total_size, 16, "Rect should be 16 bytes");
520        assert_eq!(rect.fields[0].size, 8);
521        assert_eq!(rect.fields[1].size, 8);
522        assert_eq!(rect.fields[1].offset, 8);
523    }
524
525    #[test]
526    fn nested_go_struct_resolved() {
527        let src = "package p\ntype Vec2 struct { X float32; Y float32 }\ntype Rect struct { TL Vec2; BR Vec2 }";
528        let layouts = parse_source_str(src, &SourceLanguage::Go, &X86_64_SYSV).unwrap();
529        let rect = layouts.iter().find(|l| l.name == "Rect").unwrap();
530        assert_eq!(rect.total_size, 16);
531        assert_eq!(rect.fields[0].size, 8);
532        assert_eq!(rect.fields[1].size, 8);
533        assert_eq!(rect.fields[1].offset, 8);
534    }
535
536    #[test]
537    fn primitive_types_not_shadowed_by_struct_resolution() {
538        // A struct named "u64" would be very unusual, but primitives must not be overwritten.
539        let src = "struct Wrapper { x: u64, y: bool }";
540        let layouts = parse_source_str(src, &SourceLanguage::Rust, &X86_64_SYSV).unwrap();
541        let w = &layouts[0];
542        let x = w.fields.iter().find(|f| f.name == "x").unwrap();
543        assert_eq!(x.size, 8, "u64 must stay 8 bytes");
544    }
545
546    #[test]
547    fn is_padlock_ignored_does_not_match_partial_names() {
548        // "struct Foo" annotation must not suppress "struct FooBar"
549        assert!(!is_padlock_ignored(
550            "// padlock:ignore\nstruct FooBar { int x; };",
551            "Foo"
552        ));
553    }
554
555    // ── per-finding suppression integration ───────────────────────────────────
556
557    #[test]
558    fn per_finding_suppress_reorder_in_c() {
559        // The struct has padding waste (bool before u64) — without suppression
560        // this would produce PaddingWaste + ReorderSuggestion. With the annotation
561        // on the preceding line, only PaddingWaste should survive.
562        let src = "// padlock: ignore[ReorderSuggestion]\nstruct Foo { char a; long b; };";
563        let layouts = parse_source_str(src, &SourceLanguage::C, &X86_64_SYSV).unwrap();
564        assert_eq!(layouts.len(), 1);
565        assert_eq!(layouts[0].suppressed_findings, vec!["ReorderSuggestion"]);
566    }
567
568    #[test]
569    fn per_finding_suppress_multiple_kinds_in_c() {
570        let src =
571            "// padlock: ignore[PaddingWaste, ReorderSuggestion]\nstruct Bar { char a; long b; };";
572        let layouts = parse_source_str(src, &SourceLanguage::C, &X86_64_SYSV).unwrap();
573        assert_eq!(layouts.len(), 1);
574        assert_eq!(
575            layouts[0].suppressed_findings,
576            vec!["PaddingWaste", "ReorderSuggestion"]
577        );
578    }
579
580    #[test]
581    fn per_finding_suppress_in_rust() {
582        let src = "// padlock: ignore[FalseSharing]\nstruct Foo { x: u64, y: u64 }";
583        let layouts = parse_source_str(src, &SourceLanguage::Rust, &X86_64_SYSV).unwrap();
584        assert_eq!(layouts.len(), 1);
585        assert_eq!(layouts[0].suppressed_findings, vec!["FalseSharing"]);
586    }
587
588    #[test]
589    fn per_finding_suppress_in_go() {
590        let src =
591            "package p\n// padlock: ignore[LocalityIssue]\ntype Foo struct { X int64; Y int64 }";
592        let layouts = parse_source_str(src, &SourceLanguage::Go, &X86_64_SYSV).unwrap();
593        assert_eq!(layouts.len(), 1);
594        assert_eq!(layouts[0].suppressed_findings, vec!["LocalityIssue"]);
595    }
596
597    #[test]
598    fn unannotated_struct_has_no_suppressed_findings() {
599        let src = "struct Clean { int x; int y; };";
600        let layouts = parse_source_str(src, &SourceLanguage::C, &X86_64_SYSV).unwrap();
601        assert_eq!(layouts.len(), 1);
602        assert!(layouts[0].suppressed_findings.is_empty());
603    }
604
605    // ── C++ inheritance base-size resolution ─────────────────────────────────
606
607    #[test]
608    fn cpp_inheritance_base_size_resolved_via_parse_source_str() {
609        // Base has two ints = 8 bytes. Derived inherits Base and adds one int.
610        // After resolve_nested_structs, __base_Base must be 8 bytes (not
611        // pointer-sized 8B by coincidence — we use a 4-byte base to verify).
612        let src = r#"
613class SmallBase { int x; };
614class BigDerived : public SmallBase { int a; int b; int c; };
615"#;
616        let layouts = parse_source_str(src, &SourceLanguage::Cpp, &X86_64_SYSV).unwrap();
617        let derived = layouts.iter().find(|l| l.name == "BigDerived").unwrap();
618        let base_field = derived
619            .fields
620            .iter()
621            .find(|f| f.name == "__base_SmallBase")
622            .unwrap();
623        // SmallBase is 4 bytes (single int, no padding), so after resolution
624        // the synthetic field must be 4 bytes, not 8 (pointer size).
625        assert_eq!(
626            base_field.size, 4,
627            "__base_SmallBase should be resolved to 4 bytes (sizeof SmallBase)"
628        );
629        // BigDerived total: 4 (base) + 4*3 (a,b,c) = 16 bytes
630        assert_eq!(derived.total_size, 16);
631    }
632
633    #[test]
634    fn cpp_multi_level_inheritance_resolved() {
635        let src = r#"
636class A { int x; };
637class B : public A { int y; };
638class C : public B { int z; };
639"#;
640        let layouts = parse_source_str(src, &SourceLanguage::Cpp, &X86_64_SYSV).unwrap();
641        let c = layouts.iter().find(|l| l.name == "C").unwrap();
642        // C has __base_B (which is 8 bytes: A(4)+y(4)) + z(4) = 12 bytes
643        let base_b = c.fields.iter().find(|f| f.name == "__base_B").unwrap();
644        assert_eq!(base_b.size, 8, "B is 8 bytes (A's int + B's int)");
645        assert_eq!(c.total_size, 12);
646    }
647}