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    /// Return the path or glob as an Option, avoiding the empty-string sentinel
145    /// that `path_or_glob()` returns when no path/glob is set (#1923).
146    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/// Per-ledger requirements toggles. Defaults are intentionally strict on
161/// ownership/accountability (`owner`/`reason`/`classification`/lifecycle
162/// required) and on unsafe findings (`unsafe_evidence_required: true`) while
163/// ordinary evidence is advisory by default (`evidence_required: false`).
164///
165/// This asymmetry is deliberate: an unsafe finding always needs explicit
166/// evidence even in the default profile, whereas general evidence links are
167/// encouraged but not hard-required out of the box. Promote general evidence
168/// to hard-required by setting `evidence_required = true` in the policy.
169#[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    /// Require at least one verified local-file evidence reference for unsafe
182    /// entries (#3237). Traceability-only evidence (test:, issue:, pr:, etc.)
183    /// is accepted as supplementary but does not satisfy this mandate alone.
184    /// Default: false (opt-in to tighten the governance boundary).
185    pub unsafe_verified_evidence_required: bool,
186    /// Entries created strictly before this date (YYYY-MM-DD) are exempt
187    /// from `unsafe_verified_evidence_required` — the grandfathering window
188    /// for the rollout (#3237). Entries with no `created` date are treated
189    /// as grandfathered (they predate the mandate's record-keeping). None
190    /// disables grandfathering (the mandate applies to every entry).
191    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    /// Stable identifier for the repository-owned classification rule.
216    pub id: String,
217    /// Canonical family code emitted by the file-surface classifier.
218    pub family: String,
219    /// Source-tree-relative bounded glob matched by the rule.
220    pub glob: String,
221    /// Human rationale for retaining the repository-owned rule.
222    pub reason: String,
223}
224
225/// Built-in non-Rust family codes that repository rules must not redefine.
226///
227/// Custom family classification is intentionally separate from the built-in
228/// family vocabulary. Keeping the protected names in the shared contract
229/// prevents the schema and scanner from silently disagreeing about reserved
230/// names.
231pub 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    /// Validate the core invariants of this config: a non-empty, supported
300    /// `schema_version`; a non-empty, recognized `policy` name; an optional
301    /// but recognized `status`; and a `workspace.default_mode` that parses as a
302    /// known [`WorkspaceMode`].
303    ///
304    /// This is the core-level validation entrypoint so that programmatic
305    /// consumers of `allow-core` (building an `AllowConfig` directly, not via
306    /// the TOML loader) get the same fail-closed feedback as a loaded policy.
307    /// It codifies only the invariants the core data model owns; the
308    /// `allow-policy` layer extends this with scope/glob/entry checks.
309    ///
310    /// Aggregates every problem into one error (rather than short-circuiting
311    /// on the first), so an adopter sees the full list in a single run.
312    pub fn validate(&self) -> CargoAllowResult<()> {
313        join_errors(self.validation_errors())
314    }
315
316    /// Collect every core-level validation error for this config. Public to the
317    /// crate family so `allow-policy` can fold core invariants into its own
318    /// aggregated validation without re-implementing them.
319    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
375/// Supported policy schema versions. `"1"` is accepted as a legacy alias.
376pub const SUPPORTED_SCHEMA_VERSION: &str = "0.1";
377pub const SUPPORTED_SCHEMA_VERSION_ALIAS: &str = "1";
378/// The only recognized policy name.
379pub 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    /// All policy entries considered for this finding, in deterministic policy
501    /// order. For an ambiguous result this is the structured candidate list;
502    /// consumers must not parse candidate IDs from `message`.
503    pub candidate_ids: Vec<String>,
504    pub finding_index: Option<usize>,
505    pub message: String,
506    pub score: u32,
507}