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}
113
114#[derive(Debug, serde::Serialize)]
115pub struct Report {
116    pub structs: Vec<StructReport>,
117    pub total_structs: usize,
118    pub total_wasted_bytes: usize,
119    /// Paths that were analyzed to produce this report (populated by the CLI).
120    #[serde(skip_serializing_if = "Vec::is_empty")]
121    pub analyzed_paths: Vec<String>,
122}
123
124impl Report {
125    /// Run all analysis passes over `layouts` and assemble the full report.
126    pub fn from_layouts(layouts: &[StructLayout]) -> Report {
127        let structs: Vec<StructReport> = layouts.iter().map(analyze_one).collect();
128        let total_wasted_bytes = structs.iter().map(|s| s.wasted_bytes).sum();
129        Report {
130            total_structs: structs.len(),
131            total_wasted_bytes,
132            structs,
133            analyzed_paths: Vec::new(),
134        }
135    }
136}
137
138fn analyze_one(layout: &StructLayout) -> StructReport {
139    let mut findings = Vec::new();
140
141    // ── padding waste ────────────────────────────────────────────────────────
142    let gaps = padding::find_padding(layout);
143    let num_holes = gaps.len();
144    let wasted: usize = gaps.iter().map(|g| g.bytes).sum();
145    // Unions: is_union suppresses padding at the find_padding level; no extra check needed.
146    if wasted > 0 {
147        let waste_pct = wasted as f64 / layout.total_size as f64 * 100.0;
148        let mut severity = if waste_pct >= 30.0 {
149            Severity::High
150        } else if waste_pct >= 10.0 {
151            Severity::Medium
152        } else {
153            Severity::Low
154        };
155        // repr(Rust) structs have no guaranteed layout — the compiler may already
156        // eliminate this padding.  Downgrade by one level so the finding remains
157        // visible without over-alarming on code the compiler has already handled.
158        if layout.is_repr_rust {
159            severity = severity.downgrade();
160        }
161        findings.push(Finding::PaddingWaste {
162            struct_name: layout.name.clone(),
163            total_size: layout.total_size,
164            wasted_bytes: wasted,
165            waste_pct,
166            gaps,
167            severity,
168        });
169    }
170
171    // ── reorder suggestion ───────────────────────────────────────────────────
172    // Packed structs have no padding to eliminate; union field order is irrelevant.
173    let (optimized_size, savings) = reorder::reorder_savings(layout);
174    if savings > 0 && !layout.is_packed && !layout.is_union {
175        let suggested_order = reorder::optimal_order(layout)
176            .iter()
177            .map(|f| f.name.clone())
178            .collect();
179        // repr(Rust): the compiler likely already reorders fields, so cap at Medium —
180        // the suggestion is still actionable (especially when adding repr(C) later)
181        // but should not block a High-only CI gate.
182        let severity = if layout.is_repr_rust {
183            Severity::Medium
184        } else if savings >= 8 {
185            Severity::High
186        } else {
187            Severity::Medium
188        };
189        findings.push(Finding::ReorderSuggestion {
190            struct_name: layout.name.clone(),
191            original_size: layout.total_size,
192            optimized_size,
193            savings,
194            suggested_order,
195            severity,
196        });
197    }
198
199    // ── false sharing ────────────────────────────────────────────────────────
200    // Unions place all fields at offset 0 by definition; that is not false sharing.
201    if !layout.is_union && false_sharing::has_false_sharing(layout) {
202        let conflicts = false_sharing::find_sharing_conflicts(layout);
203        // is_inferred = true when no conflicting field carries an explicit annotation.
204        let is_inferred = !layout.fields.iter().any(|f| {
205            matches!(
206                f.access,
207                AccessPattern::Concurrent {
208                    is_annotated: true,
209                    ..
210                }
211            )
212        });
213        findings.push(Finding::FalseSharing {
214            struct_name: layout.name.clone(),
215            conflicts,
216            severity: Severity::High,
217            is_inferred,
218        });
219    }
220
221    // ── locality ─────────────────────────────────────────────────────────────
222    if locality::has_locality_issue(layout) {
223        let (hot, cold) = locality::partition_hot_cold(layout);
224        // is_inferred = true when no hot field has an explicit annotation.
225        // ReadMostly is always set by the heuristic; Concurrent is annotated when
226        // is_annotated = true.
227        let is_inferred = !layout.fields.iter().any(|f| {
228            matches!(
229                f.access,
230                AccessPattern::Concurrent {
231                    is_annotated: true,
232                    ..
233                }
234            )
235        });
236        findings.push(Finding::LocalityIssue {
237            struct_name: layout.name.clone(),
238            hot_fields: hot,
239            cold_fields: cold,
240            severity: Severity::Medium,
241            is_inferred,
242        });
243    }
244
245    // ── per-finding suppression ──────────────────────────────────────────────
246    // Drop any findings whose kind_name matches a suppression directive placed
247    // in the source file (e.g. `// padlock: ignore[ReorderSuggestion]`).
248    if !layout.suppressed_findings.is_empty() {
249        findings.retain(|f| {
250            !layout
251                .suppressed_findings
252                .contains(&f.kind_name().to_string())
253        });
254    }
255
256    let score = scorer::score(layout);
257
258    StructReport {
259        struct_name: layout.name.clone(),
260        source_file: layout.source_file.clone(),
261        source_line: layout.source_line,
262        total_size: layout.total_size,
263        num_fields: layout.fields.len(),
264        num_holes,
265        wasted_bytes: wasted,
266        score,
267        findings,
268        is_repr_rust: layout.is_repr_rust,
269    }
270}
271
272// ── tests ─────────────────────────────────────────────────────────────────────
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::ir::test_fixtures::{connection_layout, packed_layout};
278
279    #[test]
280    fn report_from_misaligned_has_padding_finding() {
281        let report = Report::from_layouts(&[connection_layout()]);
282        assert_eq!(report.total_structs, 1);
283        let sr = &report.structs[0];
284        assert!(sr.wasted_bytes > 0);
285        assert!(
286            sr.findings
287                .iter()
288                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
289        );
290    }
291
292    #[test]
293    fn report_from_packed_has_no_padding_finding() {
294        let report = Report::from_layouts(&[packed_layout()]);
295        let sr = &report.structs[0];
296        assert_eq!(sr.wasted_bytes, 0);
297        assert!(
298            !sr.findings
299                .iter()
300                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
301        );
302    }
303
304    #[test]
305    fn report_from_misaligned_has_reorder_suggestion() {
306        let report = Report::from_layouts(&[connection_layout()]);
307        let sr = &report.structs[0];
308        assert!(
309            sr.findings
310                .iter()
311                .any(|f| matches!(f, Finding::ReorderSuggestion { .. }))
312        );
313    }
314
315    #[test]
316    fn severity_high_when_waste_over_30_pct() {
317        let report = Report::from_layouts(&[connection_layout()]);
318        let sr = &report.structs[0];
319        // Connection wastes 10/24 = 41% → High
320        let padding_finding = sr
321            .findings
322            .iter()
323            .find(|f| matches!(f, Finding::PaddingWaste { .. }))
324            .unwrap();
325        assert_eq!(padding_finding.severity(), &Severity::High);
326    }
327
328    #[test]
329    fn total_wasted_bytes_sums_across_structs() {
330        let report = Report::from_layouts(&[connection_layout(), packed_layout()]);
331        assert_eq!(report.total_structs, 2);
332        assert_eq!(report.total_wasted_bytes, 10); // only Connection wastes bytes
333    }
334
335    #[test]
336    fn suppressed_finding_kind_not_in_report() {
337        let mut layout = connection_layout();
338        layout.suppressed_findings = vec!["ReorderSuggestion".to_string()];
339        let report = Report::from_layouts(&[layout]);
340        let sr = &report.structs[0];
341        // PaddingWaste should still appear
342        assert!(
343            sr.findings
344                .iter()
345                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
346        );
347        // ReorderSuggestion must be suppressed
348        assert!(
349            !sr.findings
350                .iter()
351                .any(|f| matches!(f, Finding::ReorderSuggestion { .. }))
352        );
353    }
354
355    #[test]
356    fn suppressing_all_findings_yields_empty_findings() {
357        let mut layout = connection_layout();
358        layout.suppressed_findings = vec![
359            "PaddingWaste".to_string(),
360            "ReorderSuggestion".to_string(),
361            "FalseSharing".to_string(),
362            "LocalityIssue".to_string(),
363        ];
364        let report = Report::from_layouts(&[layout]);
365        assert!(report.structs[0].findings.is_empty());
366    }
367}