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 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 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 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}