Skip to main content

allow_core/
finding.rs

1use crate::{CargoAllowError, LedgerProvenance, normalize_path};
2use std::fmt;
3use std::path::PathBuf;
4use std::str::FromStr;
5
6pub const STRUCTURAL_IDENTITY_SCHEMA_ID: &str = "cargo-allow.structural-identity.v1";
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Span {
10    pub line: u32,
11    pub column: u32,
12}
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
15pub enum FindingKind {
16    Panic,
17    Unsafe,
18    LintException,
19    NonRustFile,
20    GeneratedCode,
21    PolicyException,
22}
23
24impl FindingKind {
25    pub const ALL: &[Self] = &[
26        Self::Panic,
27        Self::Unsafe,
28        Self::LintException,
29        Self::NonRustFile,
30        Self::GeneratedCode,
31        Self::PolicyException,
32    ];
33
34    pub fn as_str(self) -> &'static str {
35        match self {
36            Self::Panic => "panic",
37            Self::Unsafe => "unsafe",
38            Self::LintException => "lint_exception",
39            Self::NonRustFile => "non_rust_file",
40            Self::GeneratedCode => "generated_code",
41            Self::PolicyException => "policy_exception",
42        }
43    }
44
45    pub fn requires_source_selector_identity(self) -> bool {
46        matches!(self, Self::Panic | Self::Unsafe | Self::LintException)
47    }
48}
49
50impl fmt::Display for FindingKind {
51    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
52        write!(f, "{}", self.as_str())
53    }
54}
55
56impl FromStr for FindingKind {
57    type Err = CargoAllowError;
58
59    fn from_str(s: &str) -> Result<Self, Self::Err> {
60        let trimmed = s.trim();
61        let normalized = trimmed.to_ascii_lowercase();
62        match normalized.as_str() {
63            "panic" | "panic_family" | "panic-family" | "indexing" => Ok(Self::Panic),
64            "unsafe" => Ok(Self::Unsafe),
65            "lint_exception" | "lint-exception" | "clippy" | "allow_attribute"
66            | "allow-attribute" | "expect_attribute" | "expect-attribute" => {
67                Ok(Self::LintException)
68            }
69            "non_rust_file" | "non-rust-file" | "non_rust" | "non-rust" | "file" => {
70                Ok(Self::NonRustFile)
71            }
72            "generated_code" | "generated-code" | "generated" => Ok(Self::GeneratedCode),
73            "policy_exception" | "policy-exception" | "policy" => Ok(Self::PolicyException),
74            _ => Err(CargoAllowError::new(format!(
75                "unsupported finding kind `{trimmed}`; valid values: panic, unsafe, lint_exception, non_rust_file, generated_code, policy_exception"
76            ))),
77        }
78    }
79}
80
81/// Maximum length (bytes) of any source-derived string field in a
82/// [`StructuralIdentity`]. Caps the DoS / noisy-diff surface from a scanned
83/// file with a megabyte-long identifier (#1919). Generous enough for realistic
84/// Rust paths/identifiers (e.g. deeply-qualified module paths), small enough
85/// that an artifact cannot be inflated by a single field.
86pub const MAX_IDENTITY_FIELD_LEN: usize = 512;
87
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct StructuralIdentity {
90    pub language: String,
91    pub crate_name: Option<String>,
92    pub module: Option<String>,
93    pub container: Option<String>,
94    pub ast_kind: String,
95    pub symbol: Option<String>,
96    pub callee: Option<String>,
97    pub macro_name: Option<String>,
98    pub lint: Option<String>,
99    pub receiver_fingerprint: Option<String>,
100    pub target_fingerprint: Option<String>,
101    pub normalized_snippet_hash: Option<String>,
102    pub line_hint: Option<u32>,
103    pub column_hint: Option<u32>,
104}
105
106impl StructuralIdentity {
107    pub fn schema_id() -> &'static str {
108        STRUCTURAL_IDENTITY_SCHEMA_ID
109    }
110
111    pub fn new(language: impl Into<String>, ast_kind: impl Into<String>) -> Self {
112        Self {
113            language: language.into(),
114            crate_name: None,
115            module: None,
116            container: None,
117            ast_kind: ast_kind.into(),
118            symbol: None,
119            callee: None,
120            macro_name: None,
121            lint: None,
122            receiver_fingerprint: None,
123            target_fingerprint: None,
124            normalized_snippet_hash: None,
125            line_hint: None,
126            column_hint: None,
127        }
128    }
129
130    /// Cap every source-derived string field at [`MAX_IDENTITY_FIELD_LEN`] so a
131    /// scanned file with a megabyte-long identifier cannot inflate report/receipt
132    /// artifacts unboundedly (DoS / noisy-diff surface) (#1919). Applied in place
133    /// at the finding-construction choke point before the identity reaches any
134    /// artifact. Hashes (e.g. `normalized_snippet_hash`) are already fixed-width
135    /// and excluded.
136    pub fn truncate_in_place(&mut self) {
137        let cap_opt = |s: &mut Option<String>| {
138            if let Some(value) = s {
139                truncate_identity_field(value);
140            }
141        };
142        let cap_str = |s: &mut String| {
143            truncate_identity_field(s);
144        };
145        cap_str(&mut self.language);
146        cap_opt(&mut self.crate_name);
147        cap_opt(&mut self.module);
148        cap_opt(&mut self.container);
149        cap_str(&mut self.ast_kind);
150        cap_opt(&mut self.symbol);
151        cap_opt(&mut self.callee);
152        cap_opt(&mut self.macro_name);
153        cap_opt(&mut self.lint);
154        cap_opt(&mut self.receiver_fingerprint);
155        cap_opt(&mut self.target_fingerprint);
156    }
157
158    /// Redact the source-text-bearing identity fields (`symbol`, `callee`,
159    /// `container`, `module`, `macro_name`, `lint`) by clearing them, while
160    /// preserving the structural anchors (`normalized_snippet_hash`,
161    /// fingerprints, `ast_kind`, `line_hint`, `column_hint`) that matching
162    /// relies on. Opt-in for CI artifacts where source-text-derived fields are
163    /// an info-leak surface (#1920).
164    pub fn redact_source_text_fields(&mut self) {
165        self.symbol = None;
166        self.callee = None;
167        self.container = None;
168        self.module = None;
169        self.macro_name = None;
170        self.lint = None;
171    }
172
173    pub fn stable_key(&self) -> String {
174        stable_identity_key_from_parts(self.stable_key_parts())
175    }
176
177    pub fn stable_key_parts(&self) -> Vec<(&'static str, String)> {
178        vec![
179            ("language", self.language.clone()),
180            (
181                "crate_name",
182                self.crate_name
183                    .as_deref()
184                    .map(str::trim)
185                    .unwrap_or_default()
186                    .to_string(),
187            ),
188            ("module", self.module.clone().unwrap_or_default()),
189            ("container", self.container.clone().unwrap_or_default()),
190            ("ast_kind", self.ast_kind.clone()),
191            ("symbol", self.symbol.clone().unwrap_or_default()),
192            ("callee", self.callee.clone().unwrap_or_default()),
193            ("macro_name", self.macro_name.clone().unwrap_or_default()),
194            ("lint", self.lint.clone().unwrap_or_default()),
195            (
196                "receiver_fingerprint",
197                self.receiver_fingerprint.clone().unwrap_or_default(),
198            ),
199            (
200                "target_fingerprint",
201                self.target_fingerprint.clone().unwrap_or_default(),
202            ),
203            (
204                "normalized_snippet_hash",
205                self.normalized_snippet_hash.clone().unwrap_or_default(),
206            ),
207        ]
208    }
209}
210
211fn truncate_identity_field(value: &mut String) {
212    if value.len() <= MAX_IDENTITY_FIELD_LEN {
213        return;
214    }
215    let mut end = MAX_IDENTITY_FIELD_LEN;
216    while !value.is_char_boundary(end) {
217        end -= 1;
218    }
219    value.truncate(end);
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct Finding {
224    pub kind: FindingKind,
225    pub family: Option<String>,
226    pub path: PathBuf,
227    pub span: Option<Span>,
228    pub identity: StructuralIdentity,
229    pub message: String,
230    pub ledger: Option<LedgerProvenance>,
231}
232
233impl Finding {
234    pub fn source_package_name(&self) -> Option<&str> {
235        self.identity
236            .crate_name
237            .as_deref()
238            .map(str::trim)
239            .filter(|name| !name.is_empty())
240    }
241}
242
243pub fn finding_identity_key(finding: &Finding) -> String {
244    let mut parts = vec![
245        ("kind", finding.kind.as_str().to_string()),
246        ("family", finding.family.clone().unwrap_or_default()),
247        ("path", normalize_path(&finding.path)),
248    ];
249    parts.extend(finding.identity.stable_key_parts());
250    stable_identity_key_from_parts(parts)
251}
252
253fn stable_identity_key_from_parts(parts: Vec<(&'static str, String)>) -> String {
254    parts
255        .into_iter()
256        .map(|(name, value)| format!("{name}:{}:{value}", value.len()))
257        .collect::<Vec<_>>()
258        .join("|")
259}
260
261#[cfg(test)]
262#[path = "finding_tests.rs"]
263mod tests;