Skip to main content

allow_core/
lib.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3use std::str::FromStr;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct CargoAllowError {
7    message: String,
8}
9
10impl CargoAllowError {
11    pub fn new(message: impl Into<String>) -> Self {
12        Self {
13            message: message.into(),
14        }
15    }
16}
17
18impl fmt::Display for CargoAllowError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        write!(f, "{}", self.message)
21    }
22}
23
24impl std::error::Error for CargoAllowError {}
25
26pub type CargoAllowResult<T> = Result<T, CargoAllowError>;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29pub struct SimpleDate {
30    pub year: i32,
31    pub month: u32,
32    pub day: u32,
33}
34
35impl SimpleDate {
36    pub fn parse(input: &str) -> Option<Self> {
37        let mut parts = input.trim().split('-');
38        let year = parts.next()?.parse().ok()?;
39        let month = parts.next()?.parse().ok()?;
40        let day = parts.next()?.parse().ok()?;
41        if parts.next().is_some() || !valid_ymd(year, month, day) {
42            return None;
43        }
44        Some(Self { year, month, day })
45    }
46
47    pub fn days_until(self, other: Self) -> i64 {
48        other.days_since_unix_epoch() - self.days_since_unix_epoch()
49    }
50
51    fn days_since_unix_epoch(self) -> i64 {
52        // Howard Hinnant's civil date algorithm. This keeps lifecycle validation
53        // deterministic without adding a date dependency to the core crate.
54        let mut year = i64::from(self.year);
55        let month = i64::from(self.month);
56        let day = i64::from(self.day);
57        if month <= 2 {
58            year -= 1;
59        }
60        let era = if year >= 0 { year } else { year - 399 } / 400;
61        let year_of_era = year - era * 400;
62        let month_prime = month + if month > 2 { -3 } else { 9 };
63        let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
64        let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
65        era * 146_097 + day_of_era - 719_468
66    }
67
68    pub fn today_utc_approx() -> Self {
69        // Cargo-allow should later use a real date crate. The MVP avoids external dependencies.
70        // Current artifact generation date; good enough for deterministic fixture tests.
71        Self {
72            year: 2026,
73            month: 5,
74            day: 26,
75        }
76    }
77}
78
79fn valid_ymd(year: i32, month: u32, day: u32) -> bool {
80    let max_day = match month {
81        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
82        4 | 6 | 9 | 11 => 30,
83        2 if leap_year(year) => 29,
84        2 => 28,
85        _ => return false,
86    };
87    day > 0 && day <= max_day
88}
89
90fn leap_year(year: i32) -> bool {
91    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
92}
93
94impl fmt::Display for SimpleDate {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "{:04}-{:02}-{:02}", self.year, self.month, self.day)
97    }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Span {
102    pub line: u32,
103    pub column: u32,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
107pub enum FindingKind {
108    Panic,
109    Unsafe,
110    LintException,
111    NonRustFile,
112    GeneratedCode,
113    PolicyException,
114}
115
116impl FindingKind {
117    pub fn as_str(self) -> &'static str {
118        match self {
119            Self::Panic => "panic",
120            Self::Unsafe => "unsafe",
121            Self::LintException => "lint_exception",
122            Self::NonRustFile => "non_rust_file",
123            Self::GeneratedCode => "generated_code",
124            Self::PolicyException => "policy_exception",
125        }
126    }
127}
128
129impl fmt::Display for FindingKind {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(f, "{}", self.as_str())
132    }
133}
134
135impl FromStr for FindingKind {
136    type Err = CargoAllowError;
137
138    fn from_str(s: &str) -> Result<Self, Self::Err> {
139        match s.trim() {
140            "panic" | "panic_family" | "panic-family" | "indexing" => Ok(Self::Panic),
141            "unsafe" => Ok(Self::Unsafe),
142            "lint_exception" | "lint-exception" | "clippy" | "allow_attribute"
143            | "allow-attribute" | "expect_attribute" | "expect-attribute" => {
144                Ok(Self::LintException)
145            }
146            "non_rust_file" | "non-rust-file" | "non_rust" | "non-rust" | "file" => {
147                Ok(Self::NonRustFile)
148            }
149            "generated_code" | "generated-code" | "generated" => Ok(Self::GeneratedCode),
150            "policy_exception" | "policy-exception" | "policy" => Ok(Self::PolicyException),
151            other => Err(CargoAllowError::new(format!(
152                "unsupported finding kind `{other}`"
153            ))),
154        }
155    }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct StructuralIdentity {
160    pub language: String,
161    pub crate_name: Option<String>,
162    pub module: Option<String>,
163    pub container: Option<String>,
164    pub ast_kind: String,
165    pub symbol: Option<String>,
166    pub callee: Option<String>,
167    pub macro_name: Option<String>,
168    pub lint: Option<String>,
169    pub receiver_fingerprint: Option<String>,
170    pub target_fingerprint: Option<String>,
171    pub normalized_snippet_hash: Option<String>,
172    pub line_hint: Option<u32>,
173    pub column_hint: Option<u32>,
174}
175
176impl StructuralIdentity {
177    pub fn new(language: impl Into<String>, ast_kind: impl Into<String>) -> Self {
178        Self {
179            language: language.into(),
180            crate_name: None,
181            module: None,
182            container: None,
183            ast_kind: ast_kind.into(),
184            symbol: None,
185            callee: None,
186            macro_name: None,
187            lint: None,
188            receiver_fingerprint: None,
189            target_fingerprint: None,
190            normalized_snippet_hash: None,
191            line_hint: None,
192            column_hint: None,
193        }
194    }
195
196    pub fn stable_key(&self) -> String {
197        stable_identity_key_from_parts(self.stable_key_parts())
198    }
199
200    pub fn stable_key_parts(&self) -> Vec<(&'static str, String)> {
201        vec![
202            ("language", self.language.clone()),
203            ("crate_name", self.crate_name.clone().unwrap_or_default()),
204            ("module", self.module.clone().unwrap_or_default()),
205            ("container", self.container.clone().unwrap_or_default()),
206            ("ast_kind", self.ast_kind.clone()),
207            ("symbol", self.symbol.clone().unwrap_or_default()),
208            ("callee", self.callee.clone().unwrap_or_default()),
209            ("macro_name", self.macro_name.clone().unwrap_or_default()),
210            ("lint", self.lint.clone().unwrap_or_default()),
211            (
212                "receiver_fingerprint",
213                self.receiver_fingerprint.clone().unwrap_or_default(),
214            ),
215            (
216                "target_fingerprint",
217                self.target_fingerprint.clone().unwrap_or_default(),
218            ),
219            (
220                "normalized_snippet_hash",
221                self.normalized_snippet_hash.clone().unwrap_or_default(),
222            ),
223        ]
224    }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct Finding {
229    pub kind: FindingKind,
230    pub family: Option<String>,
231    pub path: PathBuf,
232    pub span: Option<Span>,
233    pub identity: StructuralIdentity,
234    pub message: String,
235}
236
237pub fn finding_identity_key(finding: &Finding) -> String {
238    let mut parts = vec![
239        ("kind", finding.kind.as_str().to_string()),
240        ("family", finding.family.clone().unwrap_or_default()),
241        ("path", normalize_path(&finding.path)),
242    ];
243    parts.extend(finding.identity.stable_key_parts());
244    stable_identity_key_from_parts(parts)
245}
246
247fn stable_identity_key_from_parts(parts: Vec<(&'static str, String)>) -> String {
248    parts
249        .into_iter()
250        .map(|(name, value)| format!("{name}:{}:{value}", value.len()))
251        .collect::<Vec<_>>()
252        .join("|")
253}
254
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub struct LastSeen {
257    pub line: u32,
258    pub column: u32,
259}
260
261#[derive(Debug, Clone, Default, PartialEq, Eq)]
262pub struct Selector {
263    pub ast_kind: Option<String>,
264    pub container: Option<String>,
265    pub callee: Option<String>,
266    pub macro_name: Option<String>,
267    pub lint: Option<String>,
268    pub symbol: Option<String>,
269    pub receiver_fingerprint: Option<String>,
270    pub target_fingerprint: Option<String>,
271    pub normalized_snippet_hash: Option<String>,
272    pub line_hint: Option<u32>,
273    pub glob: Option<String>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct Lifecycle {
278    pub created: Option<String>,
279    pub review_after: Option<String>,
280    pub expires: Option<String>,
281}
282
283impl Lifecycle {
284    pub fn empty() -> Self {
285        Self {
286            created: None,
287            review_after: None,
288            expires: None,
289        }
290    }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct AllowEntry {
295    pub id: String,
296    pub kind: FindingKind,
297    pub family: Option<String>,
298    pub path: Option<PathBuf>,
299    pub glob: Option<String>,
300    pub owner: String,
301    pub classification: String,
302    pub reason: String,
303    pub evidence: Vec<String>,
304    pub links: Vec<String>,
305    pub occurrence_limit: Option<u32>,
306    pub lifecycle: Lifecycle,
307    pub selector: Selector,
308    pub last_seen: Option<LastSeen>,
309}
310
311impl AllowEntry {
312    pub fn path_or_glob(&self) -> String {
313        if let Some(path) = &self.path {
314            normalize_path(path)
315        } else if let Some(glob) = &self.glob {
316            glob.clone()
317        } else if let Some(glob) = &self.selector.glob {
318            glob.clone()
319        } else {
320            String::new()
321        }
322    }
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct Requirements {
327    pub owner_required: bool,
328    pub reason_required: bool,
329    pub classification_required: bool,
330    pub expires_or_review_after_required: bool,
331    pub allow_bare_allow_attributes: bool,
332    pub stale_entries_fail: bool,
333    pub unsafe_evidence_required: bool,
334    pub unsafe_safety_comment_required: bool,
335}
336
337impl Default for Requirements {
338    fn default() -> Self {
339        Self {
340            owner_required: true,
341            reason_required: true,
342            classification_required: true,
343            expires_or_review_after_required: true,
344            allow_bare_allow_attributes: false,
345            stale_entries_fail: false,
346            unsafe_evidence_required: true,
347            unsafe_safety_comment_required: false,
348        }
349    }
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
353pub struct WorkspaceConfig {
354    pub root: String,
355    pub inventory: String,
356    pub ignored: Vec<String>,
357    pub generated: Vec<String>,
358    pub default_mode: String,
359}
360
361impl Default for WorkspaceConfig {
362    fn default() -> Self {
363        Self {
364            root: ".".to_string(),
365            inventory: "git-tracked".to_string(),
366            ignored: vec![".git/**".to_string(), "target/**".to_string()],
367            generated: vec!["target/**".to_string(), "vendor/**".to_string()],
368            default_mode: "no-new".to_string(),
369        }
370    }
371}
372
373#[derive(Debug, Clone, PartialEq, Eq)]
374pub struct AllowConfig {
375    pub schema_version: String,
376    pub policy: String,
377    pub owner: Option<String>,
378    pub status: Option<String>,
379    pub workspace: WorkspaceConfig,
380    pub requirements: Requirements,
381    pub allow: Vec<AllowEntry>,
382}
383
384impl AllowConfig {
385    pub fn empty() -> Self {
386        Self {
387            schema_version: "0.1".to_string(),
388            policy: "cargo-allow".to_string(),
389            owner: None,
390            status: Some("active".to_string()),
391            workspace: WorkspaceConfig::default(),
392            requirements: Requirements::default(),
393            allow: Vec::new(),
394        }
395    }
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
399pub enum MatchStatus {
400    Matched,
401    New,
402    Stale,
403    Expired,
404    ReviewDue,
405    Ambiguous,
406    InvalidSelector,
407    MissingRequiredField,
408    EvidenceMissing,
409    BaselineDebt,
410}
411
412impl MatchStatus {
413    pub fn as_str(self) -> &'static str {
414        match self {
415            Self::Matched => "matched",
416            Self::New => "new",
417            Self::Stale => "stale",
418            Self::Expired => "expired",
419            Self::ReviewDue => "review_due",
420            Self::Ambiguous => "ambiguous",
421            Self::InvalidSelector => "invalid_selector",
422            Self::MissingRequiredField => "missing_required_field",
423            Self::EvidenceMissing => "evidence_missing",
424            Self::BaselineDebt => "baseline_debt",
425        }
426    }
427
428    pub fn is_failure_in_strict(self) -> bool {
429        !matches!(self, Self::Matched | Self::ReviewDue)
430    }
431
432    pub fn is_failure_in_no_new(self) -> bool {
433        matches!(
434            self,
435            Self::New
436                | Self::Expired
437                | Self::Ambiguous
438                | Self::InvalidSelector
439                | Self::MissingRequiredField
440                | Self::EvidenceMissing
441        )
442    }
443}
444
445#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct MatchOutcome {
447    pub status: MatchStatus,
448    pub allow_id: Option<String>,
449    pub finding_index: Option<usize>,
450    pub message: String,
451    pub score: u32,
452}
453
454pub fn normalize_path(path: impl AsRef<Path>) -> String {
455    let text = path.as_ref().to_string_lossy().replace('\\', "/");
456    let absolute = text.starts_with('/');
457    let mut parts = Vec::new();
458    for part in text.split('/') {
459        match part {
460            "" | "." => {}
461            ".." => {
462                if parts.last().is_some_and(|part| *part != "..") {
463                    parts.pop();
464                } else if !absolute {
465                    parts.push(part);
466                }
467            }
468            other => parts.push(other),
469        }
470    }
471    let normalized = parts.join("/");
472    if absolute {
473        format!("/{normalized}")
474    } else {
475        normalized
476    }
477}
478
479pub fn normalize_snippet(input: &str) -> String {
480    input.split_whitespace().collect::<Vec<_>>().join(" ")
481}
482
483pub fn stable_hash_hex(input: &str) -> String {
484    // FNV-1a 64-bit. Not cryptographic; stable across platforms and enough for drift hints.
485    let mut hash: u64 = 0xcbf29ce484222325;
486    for byte in input.as_bytes() {
487        hash ^= u64::from(*byte);
488        hash = hash.wrapping_mul(0x100000001b3);
489    }
490    format!("fnv1a64:{hash:016x}")
491}
492
493pub fn maybe_line_distance_score(hint: Option<u32>, actual: Option<u32>) -> u32 {
494    match (hint, actual) {
495        (Some(h), Some(a)) => {
496            let diff = h.abs_diff(a);
497            if diff == 0 {
498                15
499            } else if diff <= 3 {
500                12
501            } else if diff <= 10 {
502                8
503            } else if diff <= 25 {
504                3
505            } else {
506                0
507            }
508        }
509        _ => 0,
510    }
511}
512
513pub fn glob_matches(pattern: &str, path: &Path) -> bool {
514    let path = normalize_path(path);
515    glob_matches_str(pattern, &path)
516}
517
518pub fn glob_matches_str(pattern: &str, path: &str) -> bool {
519    let p = pattern.replace('\\', "/");
520    glob_match_tokens(&split_glob(&p), &split_glob(path))
521}
522
523fn split_glob(s: &str) -> Vec<&str> {
524    s.split('/').filter(|part| !part.is_empty()).collect()
525}
526
527fn glob_match_tokens(pattern: &[&str], path: &[&str]) -> bool {
528    if pattern.is_empty() {
529        return path.is_empty();
530    }
531    if pattern[0] == "**" {
532        if glob_match_tokens(&pattern[1..], path) {
533            return true;
534        }
535        return !path.is_empty() && glob_match_tokens(pattern, &path[1..]);
536    }
537    if path.is_empty() {
538        return false;
539    }
540    segment_matches(pattern[0], path[0]) && glob_match_tokens(&pattern[1..], &path[1..])
541}
542
543fn segment_matches(pattern: &str, text: &str) -> bool {
544    segment_match_bytes(pattern.as_bytes(), text.as_bytes())
545}
546
547fn segment_match_bytes(pattern: &[u8], text: &[u8]) -> bool {
548    if pattern.is_empty() {
549        return text.is_empty();
550    }
551    match pattern[0] {
552        b'*' => {
553            segment_match_bytes(&pattern[1..], text)
554                || (!text.is_empty() && segment_match_bytes(pattern, &text[1..]))
555        }
556        b'?' => !text.is_empty() && segment_match_bytes(&pattern[1..], &text[1..]),
557        byte => {
558            !text.is_empty() && byte == text[0] && segment_match_bytes(&pattern[1..], &text[1..])
559        }
560    }
561}
562
563pub fn json_escape(input: &str) -> String {
564    let mut out = String::new();
565    for ch in input.chars() {
566        match ch {
567            '\\' => out.push_str("\\\\"),
568            '"' => out.push_str("\\\""),
569            '\n' => out.push_str("\\n"),
570            '\r' => out.push_str("\\r"),
571            '\t' => out.push_str("\\t"),
572            c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
573            c => out.push(c),
574        }
575    }
576    out
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn glob_supports_double_star() {
585        assert!(glob_matches_str("crates/**/*.rs", "crates/foo/src/lib.rs"));
586        assert!(glob_matches_str(
587            ".github/workflows/*.yml",
588            ".github/workflows/ci.yml"
589        ));
590        assert!(!glob_matches_str(
591            "scripts/*.sh",
592            "scripts/release/build.sh"
593        ));
594    }
595
596    #[test]
597    fn finding_kind_accepts_hyphenated_cli_aliases() {
598        assert_eq!(
599            FindingKind::from_str("non-rust"),
600            Ok(FindingKind::NonRustFile)
601        );
602        assert_eq!(
603            FindingKind::from_str("lint-exception"),
604            Ok(FindingKind::LintException)
605        );
606        assert_eq!(
607            FindingKind::from_str("generated-code"),
608            Ok(FindingKind::GeneratedCode)
609        );
610    }
611
612    #[test]
613    fn normalize_path_preserves_leading_parent_segments() {
614        assert_eq!(normalize_path("../src/lib.rs"), "../src/lib.rs");
615        assert_eq!(normalize_path("../../src/../README.md"), "../../README.md");
616        assert_eq!(normalize_path("src/../README.md"), "README.md");
617        assert_eq!(normalize_path(r"..\src\lib.rs"), "../src/lib.rs");
618    }
619
620    #[test]
621    fn normalize_path_preserves_absolute_unix_root() {
622        assert_eq!(normalize_path("/a/../b"), "/b");
623        assert_eq!(normalize_path("/../b"), "/b");
624        assert_eq!(normalize_path("/"), "/");
625        assert_eq!(normalize_path("/a//./b/"), "/a/b");
626    }
627
628    #[test]
629    fn hash_is_stable() {
630        assert_eq!(stable_hash_hex("abc"), stable_hash_hex("abc"));
631        assert_ne!(stable_hash_hex("abc"), stable_hash_hex("abd"));
632    }
633
634    #[test]
635    fn structural_identity_key_excludes_line_and_column_hints() {
636        let mut first = StructuralIdentity::new("rust", "method_call");
637        first.module = Some("parser::span".to_string());
638        first.container = Some("parse_span".to_string());
639        first.callee = Some("unwrap".to_string());
640        first.normalized_snippet_hash = Some("fnv1a64:1234".to_string());
641        first.line_hint = Some(12);
642        first.column_hint = Some(8);
643
644        let mut moved = first.clone();
645        moved.line_hint = Some(99);
646        moved.column_hint = Some(42);
647
648        assert_eq!(first.stable_key(), moved.stable_key());
649
650        moved.container = Some("parse_other_span".to_string());
651
652        assert_ne!(first.stable_key(), moved.stable_key());
653    }
654
655    #[test]
656    fn finding_identity_key_excludes_span_but_includes_structural_scope() {
657        let mut identity = StructuralIdentity::new("rust", "method_call");
658        identity.container = Some("load".to_string());
659        identity.callee = Some("unwrap".to_string());
660        identity.normalized_snippet_hash = Some("fnv1a64:abcd".to_string());
661
662        let mut first = Finding {
663            kind: FindingKind::Panic,
664            family: Some("unwrap".to_string()),
665            path: PathBuf::from("src/lib.rs"),
666            span: Some(Span {
667                line: 10,
668                column: 4,
669            }),
670            identity,
671            message: "test finding".to_string(),
672        };
673        let mut moved = first.clone();
674        moved.span = Some(Span {
675            line: 200,
676            column: 40,
677        });
678
679        assert_eq!(finding_identity_key(&first), finding_identity_key(&moved));
680
681        moved.path = PathBuf::from("src/other.rs");
682        assert_ne!(finding_identity_key(&first), finding_identity_key(&moved));
683
684        moved.path = first.path.clone();
685        first.family = Some("expect".to_string());
686        assert_ne!(finding_identity_key(&first), finding_identity_key(&moved));
687    }
688
689    #[test]
690    fn simple_date_rejects_invalid_calendar_dates() {
691        assert!(SimpleDate::parse("2026-02-29").is_none());
692        assert!(SimpleDate::parse("2024-02-29").is_some());
693        assert!(SimpleDate::parse("2026-04-31").is_none());
694        assert!(SimpleDate::parse("2026-13-01").is_none());
695    }
696
697    #[test]
698    fn simple_date_counts_days_between_dates() {
699        let start = SimpleDate::parse("2026-05-26")
700            .unwrap_or_else(|| std::panic::panic_any("valid start date"));
701        let end = SimpleDate::parse("2026-08-01")
702            .unwrap_or_else(|| std::panic::panic_any("valid end date"));
703
704        assert_eq!(start.days_until(end), 67);
705    }
706}