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