Skip to main content

allow_core/
policy.rs

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/// The supported workspace default check modes, mirroring the CLI `--mode`
12/// flag and the `[workspace] default_mode` policy field. A typo'd or
13/// unsupported value (e.g. `"no_new"`) is rejected at validation time rather
14/// than silently treated as a string that never matches a real mode.
15///
16/// Mirrors [`LaneEnforcementMode`]: a typed, `FromStr`-parseable enum so the
17/// valid set is codified once in core instead of duplicated by every consumer.
18#[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/// Per-ledger requirements toggles. Defaults are intentionally strict on
146/// ownership/accountability (`owner`/`reason`/`classification`/lifecycle
147/// required) and on unsafe findings (`unsafe_evidence_required: true`) while
148/// ordinary evidence is advisory by default (`evidence_required: false`).
149///
150/// This asymmetry is deliberate: an unsafe finding always needs explicit
151/// evidence even in the default profile, whereas general evidence links are
152/// encouraged but not hard-required out of the box. Promote general evidence
153/// to hard-required by setting `evidence_required = true` in the policy.
154#[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    /// Validate the core invariants of this config: a non-empty, supported
237    /// `schema_version`; a non-empty, recognized `policy` name; an optional
238    /// but recognized `status`; and a `workspace.default_mode` that parses as a
239    /// known [`WorkspaceMode`].
240    ///
241    /// This is the core-level validation entrypoint so that programmatic
242    /// consumers of `allow-core` (building an `AllowConfig` directly, not via
243    /// the TOML loader) get the same fail-closed feedback as a loaded policy.
244    /// It codifies only the invariants the core data model owns; the
245    /// `allow-policy` layer extends this with scope/glob/entry checks.
246    ///
247    /// Aggregates every problem into one error (rather than short-circuiting
248    /// on the first), so an adopter sees the full list in a single run.
249    pub fn validate(&self) -> CargoAllowResult<()> {
250        join_errors(self.validation_errors())
251    }
252
253    /// Collect every core-level validation error for this config. Public to the
254    /// crate family so `allow-policy` can fold core invariants into its own
255    /// aggregated validation without re-implementing them.
256    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
312/// Supported policy schema versions. `"1"` is accepted as a legacy alias.
313pub const SUPPORTED_SCHEMA_VERSION: &str = "0.1";
314pub const SUPPORTED_SCHEMA_VERSION_ALIAS: &str = "1";
315/// The only recognized policy name.
316pub 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    /// All policy entries considered for this finding, in deterministic policy
438    /// order. For an ambiguous result this is the structured candidate list;
439    /// consumers must not parse candidate IDs from `message`.
440    pub candidate_ids: Vec<String>,
441    pub finding_index: Option<usize>,
442    pub message: String,
443    pub score: u32,
444}