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::{PaddingGap, SharingConflict, StructLayout};
5
6#[derive(Debug, Clone, PartialEq, serde::Serialize)]
7pub enum Severity {
8    Low,
9    Medium,
10    High,
11}
12
13#[derive(Debug, Clone, serde::Serialize)]
14#[serde(tag = "kind")]
15pub enum Finding {
16    PaddingWaste {
17        struct_name: String,
18        total_size: usize,
19        wasted_bytes: usize,
20        waste_pct: f64,
21        gaps: Vec<PaddingGap>,
22        severity: Severity,
23    },
24    FalseSharing {
25        struct_name: String,
26        conflicts: Vec<SharingConflict>,
27        severity: Severity,
28    },
29    ReorderSuggestion {
30        struct_name: String,
31        original_size: usize,
32        optimized_size: usize,
33        savings: usize,
34        suggested_order: Vec<String>,
35        severity: Severity,
36    },
37    LocalityIssue {
38        struct_name: String,
39        hot_fields: Vec<String>,
40        cold_fields: Vec<String>,
41        severity: Severity,
42    },
43}
44
45impl Finding {
46    pub fn severity(&self) -> &Severity {
47        match self {
48            Finding::PaddingWaste { severity, .. } => severity,
49            Finding::FalseSharing { severity, .. } => severity,
50            Finding::ReorderSuggestion { severity, .. } => severity,
51            Finding::LocalityIssue { severity, .. } => severity,
52        }
53    }
54
55    pub fn struct_name(&self) -> &str {
56        match self {
57            Finding::PaddingWaste { struct_name, .. } => struct_name,
58            Finding::FalseSharing { struct_name, .. } => struct_name,
59            Finding::ReorderSuggestion { struct_name, .. } => struct_name,
60            Finding::LocalityIssue { struct_name, .. } => struct_name,
61        }
62    }
63
64    /// The name of the finding variant as a string, used for per-finding suppression.
65    ///
66    /// Matches the variant names used in source annotations:
67    /// `"PaddingWaste"`, `"ReorderSuggestion"`, `"FalseSharing"`, `"LocalityIssue"`.
68    pub fn kind_name(&self) -> &'static str {
69        match self {
70            Finding::PaddingWaste { .. } => "PaddingWaste",
71            Finding::FalseSharing { .. } => "FalseSharing",
72            Finding::ReorderSuggestion { .. } => "ReorderSuggestion",
73            Finding::LocalityIssue { .. } => "LocalityIssue",
74        }
75    }
76}
77
78#[derive(Debug, serde::Serialize)]
79pub struct StructReport {
80    pub struct_name: String,
81    pub source_file: Option<String>,
82    pub source_line: Option<u32>,
83    pub total_size: usize,
84    /// Number of data fields (excludes padding pseudo-fields).
85    pub num_fields: usize,
86    /// Number of byte-level padding gaps (holes) in the layout.
87    pub num_holes: usize,
88    pub wasted_bytes: usize,
89    pub score: f64,
90    pub findings: Vec<Finding>,
91    /// Mirrors `StructLayout::is_repr_rust`. When true, findings describe
92    /// declared-order waste; the compiler may have already eliminated it.
93    pub is_repr_rust: bool,
94}
95
96#[derive(Debug, serde::Serialize)]
97pub struct Report {
98    pub structs: Vec<StructReport>,
99    pub total_structs: usize,
100    pub total_wasted_bytes: usize,
101    /// Paths that were analyzed to produce this report (populated by the CLI).
102    #[serde(skip_serializing_if = "Vec::is_empty")]
103    pub analyzed_paths: Vec<String>,
104}
105
106impl Report {
107    /// Run all analysis passes over `layouts` and assemble the full report.
108    pub fn from_layouts(layouts: &[StructLayout]) -> Report {
109        let structs: Vec<StructReport> = layouts.iter().map(analyze_one).collect();
110        let total_wasted_bytes = structs.iter().map(|s| s.wasted_bytes).sum();
111        Report {
112            total_structs: structs.len(),
113            total_wasted_bytes,
114            structs,
115            analyzed_paths: Vec::new(),
116        }
117    }
118}
119
120fn analyze_one(layout: &StructLayout) -> StructReport {
121    let mut findings = Vec::new();
122
123    // ── padding waste ────────────────────────────────────────────────────────
124    let gaps = padding::find_padding(layout);
125    let num_holes = gaps.len();
126    let wasted: usize = gaps.iter().map(|g| g.bytes).sum();
127    // Unions: is_union suppresses padding at the find_padding level; no extra check needed.
128    if wasted > 0 {
129        let waste_pct = wasted as f64 / layout.total_size as f64 * 100.0;
130        let severity = if waste_pct >= 30.0 {
131            Severity::High
132        } else if waste_pct >= 10.0 {
133            Severity::Medium
134        } else {
135            Severity::Low
136        };
137        findings.push(Finding::PaddingWaste {
138            struct_name: layout.name.clone(),
139            total_size: layout.total_size,
140            wasted_bytes: wasted,
141            waste_pct,
142            gaps,
143            severity,
144        });
145    }
146
147    // ── reorder suggestion ───────────────────────────────────────────────────
148    // Packed structs have no padding to eliminate; union field order is irrelevant.
149    let (optimized_size, savings) = reorder::reorder_savings(layout);
150    if savings > 0 && !layout.is_packed && !layout.is_union {
151        let suggested_order = reorder::optimal_order(layout)
152            .iter()
153            .map(|f| f.name.clone())
154            .collect();
155        findings.push(Finding::ReorderSuggestion {
156            struct_name: layout.name.clone(),
157            original_size: layout.total_size,
158            optimized_size,
159            savings,
160            suggested_order,
161            severity: if savings >= 8 {
162                Severity::High
163            } else {
164                Severity::Medium
165            },
166        });
167    }
168
169    // ── false sharing ────────────────────────────────────────────────────────
170    // Unions place all fields at offset 0 by definition; that is not false sharing.
171    if !layout.is_union && false_sharing::has_false_sharing(layout) {
172        let conflicts = false_sharing::find_sharing_conflicts(layout);
173        findings.push(Finding::FalseSharing {
174            struct_name: layout.name.clone(),
175            conflicts,
176            severity: Severity::High,
177        });
178    }
179
180    // ── locality ─────────────────────────────────────────────────────────────
181    if locality::has_locality_issue(layout) {
182        let (hot, cold) = locality::partition_hot_cold(layout);
183        findings.push(Finding::LocalityIssue {
184            struct_name: layout.name.clone(),
185            hot_fields: hot,
186            cold_fields: cold,
187            severity: Severity::Medium,
188        });
189    }
190
191    // ── per-finding suppression ──────────────────────────────────────────────
192    // Drop any findings whose kind_name matches a suppression directive placed
193    // in the source file (e.g. `// padlock: ignore[ReorderSuggestion]`).
194    if !layout.suppressed_findings.is_empty() {
195        findings.retain(|f| {
196            !layout
197                .suppressed_findings
198                .contains(&f.kind_name().to_string())
199        });
200    }
201
202    let score = scorer::score(layout);
203
204    StructReport {
205        struct_name: layout.name.clone(),
206        source_file: layout.source_file.clone(),
207        source_line: layout.source_line,
208        total_size: layout.total_size,
209        num_fields: layout.fields.len(),
210        num_holes,
211        wasted_bytes: wasted,
212        score,
213        findings,
214        is_repr_rust: layout.is_repr_rust,
215    }
216}
217
218// ── tests ─────────────────────────────────────────────────────────────────────
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::ir::test_fixtures::{connection_layout, packed_layout};
224
225    #[test]
226    fn report_from_misaligned_has_padding_finding() {
227        let report = Report::from_layouts(&[connection_layout()]);
228        assert_eq!(report.total_structs, 1);
229        let sr = &report.structs[0];
230        assert!(sr.wasted_bytes > 0);
231        assert!(
232            sr.findings
233                .iter()
234                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
235        );
236    }
237
238    #[test]
239    fn report_from_packed_has_no_padding_finding() {
240        let report = Report::from_layouts(&[packed_layout()]);
241        let sr = &report.structs[0];
242        assert_eq!(sr.wasted_bytes, 0);
243        assert!(
244            !sr.findings
245                .iter()
246                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
247        );
248    }
249
250    #[test]
251    fn report_from_misaligned_has_reorder_suggestion() {
252        let report = Report::from_layouts(&[connection_layout()]);
253        let sr = &report.structs[0];
254        assert!(
255            sr.findings
256                .iter()
257                .any(|f| matches!(f, Finding::ReorderSuggestion { .. }))
258        );
259    }
260
261    #[test]
262    fn severity_high_when_waste_over_30_pct() {
263        let report = Report::from_layouts(&[connection_layout()]);
264        let sr = &report.structs[0];
265        // Connection wastes 10/24 = 41% → High
266        let padding_finding = sr
267            .findings
268            .iter()
269            .find(|f| matches!(f, Finding::PaddingWaste { .. }))
270            .unwrap();
271        assert_eq!(padding_finding.severity(), &Severity::High);
272    }
273
274    #[test]
275    fn total_wasted_bytes_sums_across_structs() {
276        let report = Report::from_layouts(&[connection_layout(), packed_layout()]);
277        assert_eq!(report.total_structs, 2);
278        assert_eq!(report.total_wasted_bytes, 10); // only Connection wastes bytes
279    }
280
281    #[test]
282    fn suppressed_finding_kind_not_in_report() {
283        let mut layout = connection_layout();
284        layout.suppressed_findings = vec!["ReorderSuggestion".to_string()];
285        let report = Report::from_layouts(&[layout]);
286        let sr = &report.structs[0];
287        // PaddingWaste should still appear
288        assert!(
289            sr.findings
290                .iter()
291                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
292        );
293        // ReorderSuggestion must be suppressed
294        assert!(
295            !sr.findings
296                .iter()
297                .any(|f| matches!(f, Finding::ReorderSuggestion { .. }))
298        );
299    }
300
301    #[test]
302    fn suppressing_all_findings_yields_empty_findings() {
303        let mut layout = connection_layout();
304        layout.suppressed_findings = vec![
305            "PaddingWaste".to_string(),
306            "ReorderSuggestion".to_string(),
307            "FalseSharing".to_string(),
308            "LocalityIssue".to_string(),
309        ];
310        let report = Report::from_layouts(&[layout]);
311        assert!(report.structs[0].findings.is_empty());
312    }
313}