1use crate::{
2 CargoAllowDiagnostic, CargoAllowError, CargoAllowErrorKind, CargoAllowResult, FindingKind,
3 normalize_path, source_tree_path::normalize_source_tree_scope,
4};
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7use std::str::FromStr;
8
9use crate::lane_posture::{LaneConfig, LaneEnforcementMode, lane_enforcement_mode_for_kind};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
19pub enum WorkspaceMode {
20 #[default]
21 NoNew,
22 Audit,
23 Strict,
24 Release,
25}
26
27impl WorkspaceMode {
28 pub const ALL: &[Self] = &[Self::Audit, Self::NoNew, Self::Strict, Self::Release];
29
30 pub fn as_str(self) -> &'static str {
31 match self {
32 Self::Audit => "audit",
33 Self::NoNew => "no-new",
34 Self::Strict => "strict",
35 Self::Release => "release",
36 }
37 }
38}
39
40impl FromStr for WorkspaceMode {
41 type Err = CargoAllowError;
42
43 fn from_str(value: &str) -> Result<Self, Self::Err> {
44 match value.trim() {
45 "audit" => Ok(Self::Audit),
46 "no-new" => Ok(Self::NoNew),
47 "strict" => Ok(Self::Strict),
48 "release" => Ok(Self::Release),
49 other => Err(CargoAllowError::with_kind(
50 CargoAllowErrorKind::InvalidPolicy,
51 format!("unsupported workspace default_mode `{other}`"),
52 )),
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct LastSeen {
59 pub line: u32,
60 pub column: u32,
61}
62
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct Selector {
65 pub ast_kind: Option<String>,
66 pub container: Option<String>,
67 pub callee: Option<String>,
68 pub macro_name: Option<String>,
69 pub lint: Option<String>,
70 pub symbol: Option<String>,
71 pub receiver_fingerprint: Option<String>,
72 pub target_fingerprint: Option<String>,
73 pub normalized_snippet_hash: Option<String>,
74 pub line_hint: Option<u32>,
75 pub glob: Option<String>,
76}
77
78impl Selector {
79 pub fn has_structural_identity(&self) -> bool {
80 [
81 self.ast_kind.as_deref(),
82 self.container.as_deref(),
83 self.callee.as_deref(),
84 self.macro_name.as_deref(),
85 self.lint.as_deref(),
86 self.symbol.as_deref(),
87 self.receiver_fingerprint.as_deref(),
88 self.target_fingerprint.as_deref(),
89 self.normalized_snippet_hash.as_deref(),
90 ]
91 .into_iter()
92 .any(|value| value.is_some_and(|text| !text.trim().is_empty()))
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct Lifecycle {
98 pub created: Option<String>,
99 pub review_after: Option<String>,
100 pub expires: Option<String>,
101}
102
103impl Lifecycle {
104 pub fn empty() -> Self {
105 Self {
106 created: None,
107 review_after: None,
108 expires: None,
109 }
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct AllowEntry {
115 pub id: String,
116 pub kind: FindingKind,
117 pub family: Option<String>,
118 pub path: Option<PathBuf>,
119 pub glob: Option<String>,
120 pub owner: String,
121 pub classification: String,
122 pub reason: String,
123 pub evidence: Vec<String>,
124 pub links: Vec<String>,
125 pub occurrence_limit: Option<u32>,
126 pub lifecycle: Lifecycle,
127 pub selector: Selector,
128 pub last_seen: Option<LastSeen>,
129}
130
131impl AllowEntry {
132 pub fn path_or_glob(&self) -> String {
133 if let Some(path) = &self.path {
134 normalize_path(path)
135 } else if let Some(glob) = &self.glob {
136 normalize_source_tree_scope(glob)
137 } else if let Some(glob) = &self.selector.glob {
138 normalize_source_tree_scope(glob)
139 } else {
140 String::new()
141 }
142 }
143
144 pub fn path_or_glob_opt(&self) -> Option<String> {
147 if let Some(path) = &self.path {
148 return Some(normalize_path(path));
149 }
150 if let Some(glob) = &self.glob {
151 return Some(normalize_source_tree_scope(glob));
152 }
153 self.selector
154 .glob
155 .as_deref()
156 .map(normalize_source_tree_scope)
157 }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct Requirements {
171 pub owner_required: bool,
172 pub reason_required: bool,
173 pub classification_required: bool,
174 pub evidence_required: bool,
175 pub expires_or_review_after_required: bool,
176 pub allow_bare_allow_attributes: bool,
177 pub lint_policy_id_required: bool,
178 pub stale_entries_fail: bool,
179 pub unsafe_evidence_required: bool,
180 pub unsafe_safety_comment_required: bool,
181 pub unsafe_verified_evidence_required: bool,
186 pub unsafe_verified_evidence_grandfather_entries_created_before: Option<String>,
192}
193
194impl Default for Requirements {
195 fn default() -> Self {
196 Self {
197 owner_required: true,
198 reason_required: true,
199 classification_required: true,
200 evidence_required: false,
201 expires_or_review_after_required: true,
202 allow_bare_allow_attributes: false,
203 lint_policy_id_required: false,
204 stale_entries_fail: false,
205 unsafe_evidence_required: true,
206 unsafe_safety_comment_required: false,
207 unsafe_verified_evidence_required: false,
208 unsafe_verified_evidence_grandfather_entries_created_before: None,
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct FileFamilyRule {
215 pub id: String,
217 pub family: String,
219 pub glob: String,
221 pub reason: String,
223}
224
225pub const BUILTIN_FILE_FAMILY_CODES: &[&str] = &[
232 "generated_code",
233 "ci_declarative",
234 "editor_extension",
235 "package_metadata",
236 "test_fixture",
237 "release_script",
238 "documentation",
239 "shell_script",
240 "python_tool",
241 "javascript_tool",
242 "configuration",
243 "unknown_non_rust",
244];
245
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct WorkspaceConfig {
248 pub root: String,
249 pub inventory: String,
250 pub ignored: Vec<String>,
251 pub generated: Vec<String>,
252 pub default_mode: String,
253 pub file_families: Vec<FileFamilyRule>,
254}
255
256impl Default for WorkspaceConfig {
257 fn default() -> Self {
258 Self {
259 root: ".".to_string(),
260 inventory: "git-tracked".to_string(),
261 ignored: vec![".git/**".to_string(), "target/**".to_string()],
262 generated: vec!["target/**".to_string(), "vendor/**".to_string()],
263 default_mode: "no-new".to_string(),
264 file_families: Vec::new(),
265 }
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Eq)]
270pub struct AllowConfig {
271 pub schema_version: String,
272 pub policy: String,
273 pub owner: Option<String>,
274 pub status: Option<String>,
275 pub workspace: WorkspaceConfig,
276 pub requirements: Requirements,
277 pub lanes: BTreeMap<String, LaneConfig>,
278 pub allow: Vec<AllowEntry>,
279}
280
281impl AllowConfig {
282 pub fn empty() -> Self {
283 Self {
284 schema_version: "0.1".to_string(),
285 policy: "cargo-allow".to_string(),
286 owner: None,
287 status: Some("active".to_string()),
288 workspace: WorkspaceConfig::default(),
289 requirements: Requirements::default(),
290 lanes: BTreeMap::new(),
291 allow: Vec::new(),
292 }
293 }
294
295 pub fn lane_enforcement_mode_for_kind(&self, kind: FindingKind) -> LaneEnforcementMode {
296 lane_enforcement_mode_for_kind(&self.lanes, kind)
297 }
298
299 pub fn validate(&self) -> CargoAllowResult<()> {
313 join_errors(self.validation_errors())
314 }
315
316 pub fn validation_errors(&self) -> Vec<CargoAllowError> {
320 let mut errors = Vec::new();
321 if let Err(e) = validate_schema_version(&self.schema_version) {
322 errors.push(with_core_validation_diagnostic(e, "schema_version"));
323 }
324 if let Err(e) = validate_policy_name(&self.policy) {
325 errors.push(with_core_validation_diagnostic(e, "policy"));
326 }
327 if let Err(e) = validate_optional_status(self.status.as_deref()) {
328 errors.push(with_core_validation_diagnostic(e, "status"));
329 }
330 if let Err(e) = WorkspaceMode::from_str(&self.workspace.default_mode) {
331 errors.push(with_core_validation_diagnostic(e, "workspace.default_mode"));
332 }
333 errors
334 }
335}
336
337fn join_errors(errors: Vec<CargoAllowError>) -> CargoAllowResult<()> {
338 match errors.as_slice() {
339 [] => Ok(()),
340 [single] => Err(single.clone()),
341 _ => {
342 let summary = errors
343 .iter()
344 .map(|e| format!(" - {e}"))
345 .collect::<Vec<_>>()
346 .join("\n");
347 let diagnostics = errors
348 .iter()
349 .flat_map(|error| error.diagnostics().iter().cloned())
350 .collect::<Vec<_>>();
351 Err(CargoAllowError::with_kind(
352 CargoAllowErrorKind::InvalidPolicy,
353 format!(
354 "{count} policy validation errors:\n{summary}",
355 count = errors.len()
356 ),
357 )
358 .with_diagnostics(diagnostics))
359 }
360 }
361}
362
363fn with_core_validation_diagnostic(error: CargoAllowError, field: &str) -> CargoAllowError {
364 let code = error.code();
365 let message = error.message().to_owned();
366 error.with_diagnostic(CargoAllowDiagnostic::error(
367 code,
368 "policy_validation",
369 None,
370 Some(field),
371 message,
372 ))
373}
374
375pub const SUPPORTED_SCHEMA_VERSION: &str = "0.1";
377pub const SUPPORTED_SCHEMA_VERSION_ALIAS: &str = "1";
378pub const POLICY_NAME: &str = "cargo-allow";
380
381fn require_non_empty(label: &str, value: &str) -> CargoAllowResult<()> {
382 if value.trim().is_empty() {
383 Err(CargoAllowError::with_kind(
384 CargoAllowErrorKind::InvalidPolicy,
385 format!("policy {label} must not be empty"),
386 ))
387 } else {
388 Ok(())
389 }
390}
391
392fn validate_schema_version(value: &str) -> CargoAllowResult<()> {
393 require_non_empty("schema_version", value)?;
394 if value != SUPPORTED_SCHEMA_VERSION && value != SUPPORTED_SCHEMA_VERSION_ALIAS {
395 return Err(CargoAllowError::with_kind(
396 CargoAllowErrorKind::InvalidPolicy,
397 format!("unsupported policy schema_version `{value}`"),
398 ));
399 }
400 Ok(())
401}
402
403fn validate_policy_name(value: &str) -> CargoAllowResult<()> {
404 require_non_empty("policy name", value)?;
405 if value != POLICY_NAME {
406 return Err(CargoAllowError::with_kind(
407 CargoAllowErrorKind::InvalidPolicy,
408 format!("unsupported policy `{value}`"),
409 ));
410 }
411 Ok(())
412}
413
414fn validate_optional_status(status: Option<&str>) -> CargoAllowResult<()> {
415 let Some(status) = status else {
416 return Ok(());
417 };
418 if status.trim().is_empty() {
419 return Err(CargoAllowError::with_kind(
420 CargoAllowErrorKind::InvalidPolicy,
421 "policy status must not be empty".to_string(),
422 ));
423 }
424 if !matches!(status, "active" | "advisory") {
425 return Err(CargoAllowError::with_kind(
426 CargoAllowErrorKind::InvalidPolicy,
427 format!("unsupported policy status `{status}`"),
428 ));
429 }
430 Ok(())
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
434pub enum MatchStatus {
435 Matched,
436 New,
437 Stale,
438 Expired,
439 ReviewDue,
440 LocationDrift,
441 Ambiguous,
442 InvalidSelector,
443 MissingRequiredField,
444 EvidenceMissing,
445 BaselineDebt,
446}
447
448impl MatchStatus {
449 pub const ALL: &[Self] = &[
450 Self::Matched,
451 Self::New,
452 Self::Stale,
453 Self::Expired,
454 Self::ReviewDue,
455 Self::LocationDrift,
456 Self::Ambiguous,
457 Self::InvalidSelector,
458 Self::MissingRequiredField,
459 Self::EvidenceMissing,
460 Self::BaselineDebt,
461 ];
462
463 pub fn as_str(self) -> &'static str {
464 match self {
465 Self::Matched => "matched",
466 Self::New => "new",
467 Self::Stale => "stale",
468 Self::Expired => "expired",
469 Self::ReviewDue => "review_due",
470 Self::LocationDrift => "location_drift",
471 Self::Ambiguous => "ambiguous",
472 Self::InvalidSelector => "invalid_selector",
473 Self::MissingRequiredField => "missing_required_field",
474 Self::EvidenceMissing => "evidence_missing",
475 Self::BaselineDebt => "baseline_debt",
476 }
477 }
478
479 pub fn is_failure_in_strict(self) -> bool {
480 !matches!(self, Self::Matched | Self::LocationDrift)
481 }
482
483 pub fn is_failure_in_no_new(self) -> bool {
484 matches!(
485 self,
486 Self::New
487 | Self::Expired
488 | Self::Ambiguous
489 | Self::InvalidSelector
490 | Self::MissingRequiredField
491 | Self::EvidenceMissing
492 )
493 }
494}
495
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub struct MatchOutcome {
498 pub status: MatchStatus,
499 pub allow_id: Option<String>,
500 pub candidate_ids: Vec<String>,
504 pub finding_index: Option<usize>,
505 pub message: String,
506 pub score: u32,
507}