Skip to main content

padlock_core/
findings.rs

1// padlock-core/src/findings.rs
2
3use crate::analysis::{false_sharing, locality, padding, reorder, scorer};
4use crate::ir::{AccessPattern, PaddingGap, SharingConflict, StructLayout};
5
6#[derive(Debug, Clone, PartialEq, serde::Serialize)]
7pub enum Severity {
8    Low,
9    Medium,
10    High,
11}
12
13impl Severity {
14    /// Return the next lower severity level (High→Medium, Medium→Low, Low→Low).
15    pub fn downgrade(self) -> Self {
16        match self {
17            Severity::High => Severity::Medium,
18            Severity::Medium => Severity::Low,
19            Severity::Low => Severity::Low,
20        }
21    }
22}
23
24#[derive(Debug, Clone, serde::Serialize)]
25#[serde(tag = "kind")]
26pub enum Finding {
27    PaddingWaste {
28        struct_name: String,
29        total_size: usize,
30        wasted_bytes: usize,
31        waste_pct: f64,
32        gaps: Vec<PaddingGap>,
33        severity: Severity,
34    },
35    FalseSharing {
36        struct_name: String,
37        conflicts: Vec<SharingConflict>,
38        severity: Severity,
39        /// True when every conflicting field's access pattern was inferred from its
40        /// type name rather than an explicit source annotation (`GUARDED_BY`,
41        /// `#[lock_protected_by]`, `// padlock:guard=`, etc.).
42        /// Engineers should verify inferred findings with profiling before acting.
43        is_inferred: bool,
44    },
45    ReorderSuggestion {
46        struct_name: String,
47        original_size: usize,
48        optimized_size: usize,
49        savings: usize,
50        suggested_order: Vec<String>,
51        severity: Severity,
52    },
53    LocalityIssue {
54        struct_name: String,
55        hot_fields: Vec<String>,
56        cold_fields: Vec<String>,
57        severity: Severity,
58        /// True when all hot-field classifications came from the type-name heuristic.
59        is_inferred: bool,
60    },
61}
62
63impl Finding {
64    pub fn severity(&self) -> &Severity {
65        match self {
66            Finding::PaddingWaste { severity, .. } => severity,
67            Finding::FalseSharing { severity, .. } => severity,
68            Finding::ReorderSuggestion { severity, .. } => severity,
69            Finding::LocalityIssue { severity, .. } => severity,
70        }
71    }
72
73    pub fn struct_name(&self) -> &str {
74        match self {
75            Finding::PaddingWaste { struct_name, .. } => struct_name,
76            Finding::FalseSharing { struct_name, .. } => struct_name,
77            Finding::ReorderSuggestion { struct_name, .. } => struct_name,
78            Finding::LocalityIssue { struct_name, .. } => struct_name,
79        }
80    }
81
82    /// The name of the finding variant as a string, used for per-finding suppression.
83    ///
84    /// Matches the variant names used in source annotations:
85    /// `"PaddingWaste"`, `"ReorderSuggestion"`, `"FalseSharing"`, `"LocalityIssue"`.
86    pub fn kind_name(&self) -> &'static str {
87        match self {
88            Finding::PaddingWaste { .. } => "PaddingWaste",
89            Finding::FalseSharing { .. } => "FalseSharing",
90            Finding::ReorderSuggestion { .. } => "ReorderSuggestion",
91            Finding::LocalityIssue { .. } => "LocalityIssue",
92        }
93    }
94}
95
96#[derive(Debug, serde::Serialize)]
97pub struct StructReport {
98    pub struct_name: String,
99    pub source_file: Option<String>,
100    pub source_line: Option<u32>,
101    pub total_size: usize,
102    /// Number of data fields (excludes padding pseudo-fields).
103    pub num_fields: usize,
104    /// Number of byte-level padding gaps (holes) in the layout.
105    pub num_holes: usize,
106    pub wasted_bytes: usize,
107    pub score: f64,
108    pub findings: Vec<Finding>,
109    /// Mirrors `StructLayout::is_repr_rust`. When true, findings describe
110    /// declared-order waste; the compiler may have already eliminated it.
111    pub is_repr_rust: bool,
112    /// Field names whose type size could not be accurately determined from
113    /// source alone (e.g. a qualified Go type like `driver.Connector` whose
114    /// package is not in the analyzed source set).  When non-empty, padding
115    /// and reorder findings on this struct may be inaccurate.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub uncertain_fields: Vec<String>,
118}
119
120#[derive(Debug, serde::Serialize)]
121pub struct Report {
122    pub structs: Vec<StructReport>,
123    pub total_structs: usize,
124    pub total_wasted_bytes: usize,
125    /// Paths that were analyzed to produce this report (populated by the CLI).
126    #[serde(skip_serializing_if = "Vec::is_empty")]
127    pub analyzed_paths: Vec<String>,
128    /// Maps each struct name to the list of outer struct names that embed it
129    /// as a field.  Used to surface "fixing this struct also shrinks Foo/Bar"
130    /// hints in the output.  Omitted from JSON serialisation (internal only).
131    #[serde(skip)]
132    pub embedded_in: std::collections::HashMap<String, Vec<String>>,
133}
134
135impl Report {
136    /// Run all analysis passes over `layouts` and assemble the full report.
137    pub fn from_layouts(layouts: &[StructLayout]) -> Report {
138        let structs: Vec<StructReport> = layouts.iter().map(analyze_one).collect();
139        let total_wasted_bytes = structs.iter().map(|s| s.wasted_bytes).sum();
140
141        // Build reverse-embedding map: inner_struct_name → [outer_struct_names].
142        // Any field whose TypeInfo::Opaque name matches a known struct is an embedding.
143        let struct_names: std::collections::HashSet<&str> =
144            structs.iter().map(|s| s.struct_name.as_str()).collect();
145        let mut embedded_in: std::collections::HashMap<String, Vec<String>> =
146            std::collections::HashMap::new();
147        for layout in layouts {
148            for field in &layout.fields {
149                let inner_name = match &field.ty {
150                    crate::ir::TypeInfo::Opaque { name, .. } => name.as_str(),
151                    _ => continue,
152                };
153                if struct_names.contains(inner_name) {
154                    embedded_in
155                        .entry(inner_name.to_owned())
156                        .or_default()
157                        .push(layout.name.clone());
158                }
159            }
160        }
161
162        Report {
163            total_structs: structs.len(),
164            total_wasted_bytes,
165            structs,
166            analyzed_paths: Vec::new(),
167            embedded_in,
168        }
169    }
170}
171
172fn analyze_one(layout: &StructLayout) -> StructReport {
173    let mut findings = Vec::new();
174
175    // ── padding waste ────────────────────────────────────────────────────────
176    let gaps = padding::find_padding(layout);
177    let num_holes = gaps.len();
178    let wasted: usize = gaps.iter().map(|g| g.bytes).sum();
179    // Unions: is_union suppresses padding at the find_padding level; no extra check needed.
180    if wasted > 0 {
181        let waste_pct = wasted as f64 / layout.total_size as f64 * 100.0;
182        // Percentage thresholds catch high-density waste in small structs.
183        // Absolute thresholds catch large structs where the percentage looks
184        // modest but the real cache-bandwidth cost is significant.
185        let mut severity = if waste_pct >= 30.0 || wasted >= 32 {
186            Severity::High
187        } else if waste_pct >= 10.0 || wasted >= 8 {
188            Severity::Medium
189        } else {
190            Severity::Low
191        };
192        // repr(Rust) structs have no guaranteed layout — the compiler may already
193        // eliminate this padding.  Downgrade by one level so the finding remains
194        // visible without over-alarming on code the compiler has already handled.
195        if layout.is_repr_rust {
196            severity = severity.downgrade();
197        }
198        findings.push(Finding::PaddingWaste {
199            struct_name: layout.name.clone(),
200            total_size: layout.total_size,
201            wasted_bytes: wasted,
202            waste_pct,
203            gaps,
204            severity,
205        });
206    }
207
208    // ── reorder suggestion ───────────────────────────────────────────────────
209    // Packed structs have no padding to eliminate; union field order is irrelevant.
210    let (optimized_size, savings) = reorder::reorder_savings(layout);
211    if savings > 0 && !layout.is_packed && !layout.is_union {
212        let suggested_order = reorder::optimal_order(layout)
213            .iter()
214            .map(|f| f.name.clone())
215            .collect();
216        // repr(Rust): the compiler likely already reorders fields, so cap at Medium —
217        // the suggestion is still actionable (especially when adding repr(C) later)
218        // but should not block a High-only CI gate.
219        let severity = if layout.is_repr_rust {
220            Severity::Medium
221        } else if savings >= 8 {
222            Severity::High
223        } else {
224            Severity::Medium
225        };
226        findings.push(Finding::ReorderSuggestion {
227            struct_name: layout.name.clone(),
228            original_size: layout.total_size,
229            optimized_size,
230            savings,
231            suggested_order,
232            severity,
233        });
234    }
235
236    // ── false sharing ────────────────────────────────────────────────────────
237    // Unions place all fields at offset 0 by definition; that is not false sharing.
238    if !layout.is_union && false_sharing::has_false_sharing(layout) {
239        let conflicts = false_sharing::find_sharing_conflicts(layout);
240        // is_inferred = true when no conflicting field carries an explicit annotation.
241        let is_inferred = !layout.fields.iter().any(|f| {
242            matches!(
243                f.access,
244                AccessPattern::Concurrent {
245                    is_annotated: true,
246                    ..
247                }
248            )
249        });
250        findings.push(Finding::FalseSharing {
251            struct_name: layout.name.clone(),
252            conflicts,
253            severity: Severity::High,
254            is_inferred,
255        });
256    }
257
258    // ── locality ─────────────────────────────────────────────────────────────
259    if locality::has_locality_issue(layout) {
260        let (hot, cold) = locality::partition_hot_cold(layout);
261        // is_inferred = true when no hot field has an explicit annotation.
262        // ReadMostly is always set by the heuristic; Concurrent is annotated when
263        // is_annotated = true.
264        let is_inferred = !layout.fields.iter().any(|f| {
265            matches!(
266                f.access,
267                AccessPattern::Concurrent {
268                    is_annotated: true,
269                    ..
270                }
271            )
272        });
273        findings.push(Finding::LocalityIssue {
274            struct_name: layout.name.clone(),
275            hot_fields: hot,
276            cold_fields: cold,
277            severity: Severity::Medium,
278            is_inferred,
279        });
280    }
281
282    // ── per-finding suppression ──────────────────────────────────────────────
283    // Drop any findings whose kind_name matches a suppression directive placed
284    // in the source file (e.g. `// padlock: ignore[ReorderSuggestion]`).
285    if !layout.suppressed_findings.is_empty() {
286        findings.retain(|f| {
287            !layout
288                .suppressed_findings
289                .contains(&f.kind_name().to_string())
290        });
291    }
292
293    let score = scorer::score(layout);
294
295    StructReport {
296        struct_name: layout.name.clone(),
297        source_file: layout.source_file.clone(),
298        source_line: layout.source_line,
299        total_size: layout.total_size,
300        num_fields: layout.fields.len(),
301        num_holes,
302        wasted_bytes: wasted,
303        score,
304        findings,
305        is_repr_rust: layout.is_repr_rust,
306        uncertain_fields: layout.uncertain_fields.clone(),
307    }
308}
309
310// ── tests ─────────────────────────────────────────────────────────────────────
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::ir::test_fixtures::{connection_layout, packed_layout};
316
317    #[test]
318    fn report_from_misaligned_has_padding_finding() {
319        let report = Report::from_layouts(&[connection_layout()]);
320        assert_eq!(report.total_structs, 1);
321        let sr = &report.structs[0];
322        assert!(sr.wasted_bytes > 0);
323        assert!(
324            sr.findings
325                .iter()
326                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
327        );
328    }
329
330    #[test]
331    fn report_from_packed_has_no_padding_finding() {
332        let report = Report::from_layouts(&[packed_layout()]);
333        let sr = &report.structs[0];
334        assert_eq!(sr.wasted_bytes, 0);
335        assert!(
336            !sr.findings
337                .iter()
338                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
339        );
340    }
341
342    #[test]
343    fn report_from_misaligned_has_reorder_suggestion() {
344        let report = Report::from_layouts(&[connection_layout()]);
345        let sr = &report.structs[0];
346        assert!(
347            sr.findings
348                .iter()
349                .any(|f| matches!(f, Finding::ReorderSuggestion { .. }))
350        );
351    }
352
353    #[test]
354    fn severity_high_when_waste_over_30_pct() {
355        let report = Report::from_layouts(&[connection_layout()]);
356        let sr = &report.structs[0];
357        // Connection wastes 10/24 = 41% → High
358        let padding_finding = sr
359            .findings
360            .iter()
361            .find(|f| matches!(f, Finding::PaddingWaste { .. }))
362            .unwrap();
363        assert_eq!(padding_finding.severity(), &Severity::High);
364    }
365
366    #[test]
367    fn total_wasted_bytes_sums_across_structs() {
368        let report = Report::from_layouts(&[connection_layout(), packed_layout()]);
369        assert_eq!(report.total_structs, 2);
370        assert_eq!(report.total_wasted_bytes, 10); // only Connection wastes bytes
371    }
372
373    #[test]
374    fn suppressed_finding_kind_not_in_report() {
375        let mut layout = connection_layout();
376        layout.suppressed_findings = vec!["ReorderSuggestion".to_string()];
377        let report = Report::from_layouts(&[layout]);
378        let sr = &report.structs[0];
379        // PaddingWaste should still appear
380        assert!(
381            sr.findings
382                .iter()
383                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
384        );
385        // ReorderSuggestion must be suppressed
386        assert!(
387            !sr.findings
388                .iter()
389                .any(|f| matches!(f, Finding::ReorderSuggestion { .. }))
390        );
391    }
392
393    #[test]
394    fn suppressing_all_findings_yields_empty_findings() {
395        let mut layout = connection_layout();
396        layout.suppressed_findings = vec![
397            "PaddingWaste".to_string(),
398            "ReorderSuggestion".to_string(),
399            "FalseSharing".to_string(),
400            "LocalityIssue".to_string(),
401        ];
402        let report = Report::from_layouts(&[layout]);
403        assert!(report.structs[0].findings.is_empty());
404    }
405}