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/// A struct/type that was skipped during analysis because its layout cannot be
121/// accurately determined from source alone (e.g. generic/template types).
122#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
123pub struct SkippedStruct {
124    pub name: String,
125    pub reason: String,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub source_file: Option<String>,
128}
129
130#[derive(Debug, serde::Serialize)]
131pub struct Report {
132    pub structs: Vec<StructReport>,
133    pub total_structs: usize,
134    pub total_wasted_bytes: usize,
135    /// Paths that were analyzed to produce this report (populated by the CLI).
136    #[serde(skip_serializing_if = "Vec::is_empty")]
137    pub analyzed_paths: Vec<String>,
138    /// Structs that were skipped during analysis (e.g. generics/templates whose
139    /// layout cannot be determined from source alone). Populated by the CLI.
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub skipped: Vec<SkippedStruct>,
142    /// Maps each struct name to the list of outer struct names that embed it
143    /// as a field.  Used to surface "fixing this struct also shrinks Foo/Bar"
144    /// hints in the output.  Omitted from JSON serialisation (internal only).
145    #[serde(skip)]
146    pub embedded_in: std::collections::HashMap<String, Vec<String>>,
147}
148
149impl Report {
150    /// Run all analysis passes over `layouts` and assemble the full report.
151    pub fn from_layouts(layouts: &[StructLayout]) -> Report {
152        let structs: Vec<StructReport> = layouts.iter().map(analyze_one).collect();
153        let total_wasted_bytes = structs.iter().map(|s| s.wasted_bytes).sum();
154
155        // Build reverse-embedding map: inner_struct_name → [outer_struct_names].
156        // Any field whose TypeInfo::Opaque name matches a known struct is an embedding.
157        let struct_names: std::collections::HashSet<&str> =
158            structs.iter().map(|s| s.struct_name.as_str()).collect();
159        let mut embedded_in: std::collections::HashMap<String, Vec<String>> =
160            std::collections::HashMap::new();
161        for layout in layouts {
162            for field in &layout.fields {
163                let inner_name = match &field.ty {
164                    crate::ir::TypeInfo::Opaque { name, .. } => name.as_str(),
165                    _ => continue,
166                };
167                if struct_names.contains(inner_name) {
168                    embedded_in
169                        .entry(inner_name.to_owned())
170                        .or_default()
171                        .push(layout.name.clone());
172                }
173            }
174        }
175
176        Report {
177            total_structs: structs.len(),
178            total_wasted_bytes,
179            structs,
180            analyzed_paths: Vec::new(),
181            skipped: Vec::new(),
182            embedded_in,
183        }
184    }
185}
186
187fn analyze_one(layout: &StructLayout) -> StructReport {
188    let mut findings = Vec::new();
189
190    // ── padding waste ────────────────────────────────────────────────────────
191    let gaps = padding::find_padding(layout);
192    let num_holes = gaps.len();
193    let wasted: usize = gaps.iter().map(|g| g.bytes).sum();
194    // Unions: is_union suppresses padding at the find_padding level; no extra check needed.
195    if wasted > 0 {
196        let waste_pct = wasted as f64 / layout.total_size as f64 * 100.0;
197        // Percentage thresholds catch high-density waste in small structs.
198        // Absolute thresholds catch large structs where the percentage looks
199        // modest but the real cache-bandwidth cost is significant.
200        let mut severity = if waste_pct >= 30.0 || wasted >= 32 {
201            Severity::High
202        } else if waste_pct >= 10.0 || wasted >= 8 {
203            Severity::Medium
204        } else {
205            Severity::Low
206        };
207        // repr(Rust) structs have no guaranteed layout — the compiler may already
208        // eliminate this padding.  Downgrade by one level so the finding remains
209        // visible without over-alarming on code the compiler has already handled.
210        if layout.is_repr_rust {
211            severity = severity.downgrade();
212        }
213        findings.push(Finding::PaddingWaste {
214            struct_name: layout.name.clone(),
215            total_size: layout.total_size,
216            wasted_bytes: wasted,
217            waste_pct,
218            gaps,
219            severity,
220        });
221    }
222
223    // ── reorder suggestion ───────────────────────────────────────────────────
224    // Packed structs have no padding to eliminate; union field order is irrelevant.
225    let (optimized_size, savings) = reorder::reorder_savings(layout);
226    if savings > 0 && !layout.is_packed && !layout.is_union {
227        let suggested_order = reorder::optimal_order(layout)
228            .iter()
229            .map(|f| f.name.clone())
230            .collect();
231        // repr(Rust): the compiler likely already reorders fields, so cap at Medium —
232        // the suggestion is still actionable (especially when adding repr(C) later)
233        // but should not block a High-only CI gate.
234        let severity = if layout.is_repr_rust {
235            Severity::Medium
236        } else if savings >= 8 {
237            Severity::High
238        } else {
239            Severity::Medium
240        };
241        findings.push(Finding::ReorderSuggestion {
242            struct_name: layout.name.clone(),
243            original_size: layout.total_size,
244            optimized_size,
245            savings,
246            suggested_order,
247            severity,
248        });
249    }
250
251    // ── false sharing ────────────────────────────────────────────────────────
252    // Unions place all fields at offset 0 by definition; that is not false sharing.
253    if !layout.is_union && false_sharing::has_false_sharing(layout) {
254        let conflicts = false_sharing::find_sharing_conflicts(layout);
255        // is_inferred = true when no conflicting field carries an explicit annotation.
256        let is_inferred = !layout.fields.iter().any(|f| {
257            matches!(
258                f.access,
259                AccessPattern::Concurrent {
260                    is_annotated: true,
261                    ..
262                }
263            )
264        });
265        findings.push(Finding::FalseSharing {
266            struct_name: layout.name.clone(),
267            conflicts,
268            severity: Severity::High,
269            is_inferred,
270        });
271    }
272
273    // ── locality ─────────────────────────────────────────────────────────────
274    if locality::has_locality_issue(layout) {
275        let (hot, cold) = locality::partition_hot_cold(layout);
276        // is_inferred = true when no hot field has an explicit annotation.
277        // ReadMostly is always set by the heuristic; Concurrent is annotated when
278        // is_annotated = true.
279        let is_inferred = !layout.fields.iter().any(|f| {
280            matches!(
281                f.access,
282                AccessPattern::Concurrent {
283                    is_annotated: true,
284                    ..
285                }
286            )
287        });
288        findings.push(Finding::LocalityIssue {
289            struct_name: layout.name.clone(),
290            hot_fields: hot,
291            cold_fields: cold,
292            severity: Severity::Medium,
293            is_inferred,
294        });
295    }
296
297    // ── per-finding suppression ──────────────────────────────────────────────
298    // Drop any findings whose kind_name matches a suppression directive placed
299    // in the source file (e.g. `// padlock: ignore[ReorderSuggestion]`).
300    if !layout.suppressed_findings.is_empty() {
301        findings.retain(|f| {
302            !layout
303                .suppressed_findings
304                .contains(&f.kind_name().to_string())
305        });
306    }
307
308    let score = scorer::score(layout);
309
310    StructReport {
311        struct_name: layout.name.clone(),
312        source_file: layout.source_file.clone(),
313        source_line: layout.source_line,
314        total_size: layout.total_size,
315        num_fields: layout.fields.len(),
316        num_holes,
317        wasted_bytes: wasted,
318        score,
319        findings,
320        is_repr_rust: layout.is_repr_rust,
321        uncertain_fields: layout.uncertain_fields.clone(),
322    }
323}
324
325// ── tests ─────────────────────────────────────────────────────────────────────
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::ir::test_fixtures::{connection_layout, packed_layout};
331
332    #[test]
333    fn report_from_misaligned_has_padding_finding() {
334        let report = Report::from_layouts(&[connection_layout()]);
335        assert_eq!(report.total_structs, 1);
336        let sr = &report.structs[0];
337        assert!(sr.wasted_bytes > 0);
338        assert!(
339            sr.findings
340                .iter()
341                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
342        );
343    }
344
345    #[test]
346    fn report_from_packed_has_no_padding_finding() {
347        let report = Report::from_layouts(&[packed_layout()]);
348        let sr = &report.structs[0];
349        assert_eq!(sr.wasted_bytes, 0);
350        assert!(
351            !sr.findings
352                .iter()
353                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
354        );
355    }
356
357    #[test]
358    fn report_from_misaligned_has_reorder_suggestion() {
359        let report = Report::from_layouts(&[connection_layout()]);
360        let sr = &report.structs[0];
361        assert!(
362            sr.findings
363                .iter()
364                .any(|f| matches!(f, Finding::ReorderSuggestion { .. }))
365        );
366    }
367
368    #[test]
369    fn severity_high_when_waste_over_30_pct() {
370        let report = Report::from_layouts(&[connection_layout()]);
371        let sr = &report.structs[0];
372        // Connection wastes 10/24 = 41% → High
373        let padding_finding = sr
374            .findings
375            .iter()
376            .find(|f| matches!(f, Finding::PaddingWaste { .. }))
377            .unwrap();
378        assert_eq!(padding_finding.severity(), &Severity::High);
379    }
380
381    #[test]
382    fn total_wasted_bytes_sums_across_structs() {
383        let report = Report::from_layouts(&[connection_layout(), packed_layout()]);
384        assert_eq!(report.total_structs, 2);
385        assert_eq!(report.total_wasted_bytes, 10); // only Connection wastes bytes
386    }
387
388    #[test]
389    fn suppressed_finding_kind_not_in_report() {
390        let mut layout = connection_layout();
391        layout.suppressed_findings = vec!["ReorderSuggestion".to_string()];
392        let report = Report::from_layouts(&[layout]);
393        let sr = &report.structs[0];
394        // PaddingWaste should still appear
395        assert!(
396            sr.findings
397                .iter()
398                .any(|f| matches!(f, Finding::PaddingWaste { .. }))
399        );
400        // ReorderSuggestion must be suppressed
401        assert!(
402            !sr.findings
403                .iter()
404                .any(|f| matches!(f, Finding::ReorderSuggestion { .. }))
405        );
406    }
407
408    #[test]
409    fn suppressing_all_findings_yields_empty_findings() {
410        let mut layout = connection_layout();
411        layout.suppressed_findings = vec![
412            "PaddingWaste".to_string(),
413            "ReorderSuggestion".to_string(),
414            "FalseSharing".to_string(),
415            "LocalityIssue".to_string(),
416        ];
417        let report = Report::from_layouts(&[layout]);
418        assert!(report.structs[0].findings.is_empty());
419    }
420}