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
145#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct Requirements {
156 pub owner_required: bool,
157 pub reason_required: bool,
158 pub classification_required: bool,
159 pub evidence_required: bool,
160 pub expires_or_review_after_required: bool,
161 pub allow_bare_allow_attributes: bool,
162 pub lint_policy_id_required: bool,
163 pub stale_entries_fail: bool,
164 pub unsafe_evidence_required: bool,
165 pub unsafe_safety_comment_required: bool,
166}
167
168impl Default for Requirements {
169 fn default() -> Self {
170 Self {
171 owner_required: true,
172 reason_required: true,
173 classification_required: true,
174 evidence_required: false,
175 expires_or_review_after_required: true,
176 allow_bare_allow_attributes: false,
177 lint_policy_id_required: false,
178 stale_entries_fail: false,
179 unsafe_evidence_required: true,
180 unsafe_safety_comment_required: false,
181 }
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct WorkspaceConfig {
187 pub root: String,
188 pub inventory: String,
189 pub ignored: Vec<String>,
190 pub generated: Vec<String>,
191 pub default_mode: String,
192}
193
194impl Default for WorkspaceConfig {
195 fn default() -> Self {
196 Self {
197 root: ".".to_string(),
198 inventory: "git-tracked".to_string(),
199 ignored: vec![".git/**".to_string(), "target/**".to_string()],
200 generated: vec!["target/**".to_string(), "vendor/**".to_string()],
201 default_mode: "no-new".to_string(),
202 }
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct AllowConfig {
208 pub schema_version: String,
209 pub policy: String,
210 pub owner: Option<String>,
211 pub status: Option<String>,
212 pub workspace: WorkspaceConfig,
213 pub requirements: Requirements,
214 pub lanes: BTreeMap<String, LaneConfig>,
215 pub allow: Vec<AllowEntry>,
216}
217
218impl AllowConfig {
219 pub fn empty() -> Self {
220 Self {
221 schema_version: "0.1".to_string(),
222 policy: "cargo-allow".to_string(),
223 owner: None,
224 status: Some("active".to_string()),
225 workspace: WorkspaceConfig::default(),
226 requirements: Requirements::default(),
227 lanes: BTreeMap::new(),
228 allow: Vec::new(),
229 }
230 }
231
232 pub fn lane_enforcement_mode_for_kind(&self, kind: FindingKind) -> LaneEnforcementMode {
233 lane_enforcement_mode_for_kind(&self.lanes, kind)
234 }
235
236 pub fn validate(&self) -> CargoAllowResult<()> {
250 join_errors(self.validation_errors())
251 }
252
253 pub fn validation_errors(&self) -> Vec<CargoAllowError> {
257 let mut errors = Vec::new();
258 if let Err(e) = validate_schema_version(&self.schema_version) {
259 errors.push(with_core_validation_diagnostic(e, "schema_version"));
260 }
261 if let Err(e) = validate_policy_name(&self.policy) {
262 errors.push(with_core_validation_diagnostic(e, "policy"));
263 }
264 if let Err(e) = validate_optional_status(self.status.as_deref()) {
265 errors.push(with_core_validation_diagnostic(e, "status"));
266 }
267 if let Err(e) = WorkspaceMode::from_str(&self.workspace.default_mode) {
268 errors.push(with_core_validation_diagnostic(e, "workspace.default_mode"));
269 }
270 errors
271 }
272}
273
274fn join_errors(errors: Vec<CargoAllowError>) -> CargoAllowResult<()> {
275 match errors.as_slice() {
276 [] => Ok(()),
277 [single] => Err(single.clone()),
278 _ => {
279 let summary = errors
280 .iter()
281 .map(|e| format!(" - {e}"))
282 .collect::<Vec<_>>()
283 .join("\n");
284 let diagnostics = errors
285 .iter()
286 .flat_map(|error| error.diagnostics().iter().cloned())
287 .collect::<Vec<_>>();
288 Err(CargoAllowError::with_kind(
289 CargoAllowErrorKind::InvalidPolicy,
290 format!(
291 "{count} policy validation errors:\n{summary}",
292 count = errors.len()
293 ),
294 )
295 .with_diagnostics(diagnostics))
296 }
297 }
298}
299
300fn with_core_validation_diagnostic(error: CargoAllowError, field: &str) -> CargoAllowError {
301 let code = error.code();
302 let message = error.message().to_owned();
303 error.with_diagnostic(CargoAllowDiagnostic::error(
304 code,
305 "policy_validation",
306 None,
307 Some(field),
308 message,
309 ))
310}
311
312pub const SUPPORTED_SCHEMA_VERSION: &str = "0.1";
314pub const SUPPORTED_SCHEMA_VERSION_ALIAS: &str = "1";
315pub const POLICY_NAME: &str = "cargo-allow";
317
318fn require_non_empty(label: &str, value: &str) -> CargoAllowResult<()> {
319 if value.trim().is_empty() {
320 Err(CargoAllowError::with_kind(
321 CargoAllowErrorKind::InvalidPolicy,
322 format!("policy {label} must not be empty"),
323 ))
324 } else {
325 Ok(())
326 }
327}
328
329fn validate_schema_version(value: &str) -> CargoAllowResult<()> {
330 require_non_empty("schema_version", value)?;
331 if value != SUPPORTED_SCHEMA_VERSION && value != SUPPORTED_SCHEMA_VERSION_ALIAS {
332 return Err(CargoAllowError::with_kind(
333 CargoAllowErrorKind::InvalidPolicy,
334 format!("unsupported policy schema_version `{value}`"),
335 ));
336 }
337 Ok(())
338}
339
340fn validate_policy_name(value: &str) -> CargoAllowResult<()> {
341 require_non_empty("policy name", value)?;
342 if value != POLICY_NAME {
343 return Err(CargoAllowError::with_kind(
344 CargoAllowErrorKind::InvalidPolicy,
345 format!("unsupported policy `{value}`"),
346 ));
347 }
348 Ok(())
349}
350
351fn validate_optional_status(status: Option<&str>) -> CargoAllowResult<()> {
352 let Some(status) = status else {
353 return Ok(());
354 };
355 if status.trim().is_empty() {
356 return Err(CargoAllowError::with_kind(
357 CargoAllowErrorKind::InvalidPolicy,
358 "policy status must not be empty".to_string(),
359 ));
360 }
361 if !matches!(status, "active" | "advisory") {
362 return Err(CargoAllowError::with_kind(
363 CargoAllowErrorKind::InvalidPolicy,
364 format!("unsupported policy status `{status}`"),
365 ));
366 }
367 Ok(())
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
371pub enum MatchStatus {
372 Matched,
373 New,
374 Stale,
375 Expired,
376 ReviewDue,
377 LocationDrift,
378 Ambiguous,
379 InvalidSelector,
380 MissingRequiredField,
381 EvidenceMissing,
382 BaselineDebt,
383}
384
385impl MatchStatus {
386 pub const ALL: &[Self] = &[
387 Self::Matched,
388 Self::New,
389 Self::Stale,
390 Self::Expired,
391 Self::ReviewDue,
392 Self::LocationDrift,
393 Self::Ambiguous,
394 Self::InvalidSelector,
395 Self::MissingRequiredField,
396 Self::EvidenceMissing,
397 Self::BaselineDebt,
398 ];
399
400 pub fn as_str(self) -> &'static str {
401 match self {
402 Self::Matched => "matched",
403 Self::New => "new",
404 Self::Stale => "stale",
405 Self::Expired => "expired",
406 Self::ReviewDue => "review_due",
407 Self::LocationDrift => "location_drift",
408 Self::Ambiguous => "ambiguous",
409 Self::InvalidSelector => "invalid_selector",
410 Self::MissingRequiredField => "missing_required_field",
411 Self::EvidenceMissing => "evidence_missing",
412 Self::BaselineDebt => "baseline_debt",
413 }
414 }
415
416 pub fn is_failure_in_strict(self) -> bool {
417 !matches!(self, Self::Matched | Self::LocationDrift)
418 }
419
420 pub fn is_failure_in_no_new(self) -> bool {
421 matches!(
422 self,
423 Self::New
424 | Self::Expired
425 | Self::Ambiguous
426 | Self::InvalidSelector
427 | Self::MissingRequiredField
428 | Self::EvidenceMissing
429 )
430 }
431}
432
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct MatchOutcome {
435 pub status: MatchStatus,
436 pub allow_id: Option<String>,
437 pub candidate_ids: Vec<String>,
441 pub finding_index: Option<usize>,
442 pub message: String,
443 pub score: u32,
444}