Skip to main content

rustinel_core/
policy.rs

1use crate::errors::RustinelError;
2use crate::risk::ProjectRisk;
3use crate::signals::RiskSignal;
4use serde::{Deserialize, Serialize};
5
6/// Raw, on-disk policy as parsed from `rustinel.toml`. All sections are optional
7/// and unknown fields are ignored (forward-compatible; see POLICY_SPEC §7).
8#[derive(Debug, Clone, Default, Serialize, Deserialize)]
9pub struct Policy {
10    pub version: Option<u32>,
11    pub profile: Option<PolicyProfile>,
12    pub risk: Option<RiskPolicy>,
13    pub advisories: Option<AdvisoriesPolicy>,
14    pub signals: Option<SignalsPolicy>,
15    pub licenses: Option<LicensesPolicy>,
16    pub allow: Option<ListPolicy>,
17    pub deny: Option<ListPolicy>,
18}
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
21pub struct PolicyProfile {
22    pub name: String,
23}
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
26pub struct RiskPolicy {
27    pub max_project_score: Option<u8>,
28    pub max_package_score: Option<u8>,
29    pub fail_on_delta_above: Option<i32>,
30    pub warn_on_delta_above: Option<i32>,
31}
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34pub struct AdvisoriesPolicy {
35    #[serde(default)]
36    pub fail_on: Vec<String>,
37    #[serde(default)]
38    pub warn_on: Vec<String>,
39    #[serde(default)]
40    pub ignore: Vec<String>,
41}
42
43#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct SignalsPolicy {
45    pub fail_on_yanked: Option<bool>,
46    pub warn_on_build_rs: Option<bool>,
47    pub require_review_on_build_rs: Option<bool>,
48    pub require_review_on_native_ffi: Option<bool>,
49    pub fail_on_denied_license: Option<bool>,
50    pub warn_on_unknown_license: Option<bool>,
51    pub fail_on_unknown_license: Option<bool>,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
55pub struct LicensesPolicy {
56    #[serde(default)]
57    pub allow: Vec<String>,
58    #[serde(default)]
59    pub deny: Vec<String>,
60}
61
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct ListPolicy {
64    #[serde(default)]
65    pub crates: Vec<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct PolicyDecision {
70    pub decision: Decision,
71    pub profile: String,
72    pub violations: Vec<String>,
73    pub warnings: Vec<String>,
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    pub review_items: Vec<String>,
76    /// Advisory IDs waived by policy (`advisories.ignore`). Surfaced so a VEX
77    /// export can mark them `not_affected` rather than `affected`.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    pub ignored_advisories: Vec<String>,
80}
81
82#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "snake_case")]
84pub enum Decision {
85    Pass,
86    Warn,
87    Fail,
88    ReviewRequired,
89}
90
91impl Decision {
92    pub fn as_str(&self) -> &'static str {
93        match self {
94            Decision::Pass => "pass",
95            Decision::Warn => "warn",
96            Decision::Fail => "fail",
97            Decision::ReviewRequired => "review_required",
98        }
99    }
100}
101
102/// Fully-resolved policy configuration: profile defaults overlaid with any
103/// explicit values from the file.
104struct Effective {
105    profile: String,
106    max_project_score: u8,
107    max_package_score: u8,
108    fail_on_delta_above: i32,
109    warn_on_delta_above: i32,
110    adv_fail_on: Vec<String>,
111    adv_warn_on: Vec<String>,
112    adv_ignore: Vec<String>,
113    warn_on_build_rs: bool,
114    require_review_on_build_rs: bool,
115    require_review_on_native_ffi: bool,
116    fail_on_yanked: bool,
117    fail_on_denied_license: bool,
118    warn_on_unknown_license: bool,
119    fail_on_unknown_license: bool,
120    license_allow: Vec<String>,
121    license_deny: Vec<String>,
122    allow_crates: Vec<String>,
123}
124
125impl Effective {
126    fn from(policy: Option<&Policy>) -> Self {
127        let profile_name = policy
128            .and_then(|p| p.profile.as_ref())
129            .map(|p| p.name.clone())
130            .unwrap_or_else(|| "balanced".into());
131        let mut eff = Self::defaults_for(&profile_name);
132
133        let Some(policy) = policy else { return eff };
134
135        if let Some(r) = &policy.risk {
136            if let Some(v) = r.max_project_score {
137                eff.max_project_score = v;
138            }
139            if let Some(v) = r.max_package_score {
140                eff.max_package_score = v;
141            }
142            if let Some(v) = r.fail_on_delta_above {
143                eff.fail_on_delta_above = v;
144            }
145            if let Some(v) = r.warn_on_delta_above {
146                eff.warn_on_delta_above = v;
147            }
148        }
149        if let Some(a) = &policy.advisories {
150            if !a.fail_on.is_empty() {
151                eff.adv_fail_on = a.fail_on.clone();
152            }
153            if !a.warn_on.is_empty() {
154                eff.adv_warn_on = a.warn_on.clone();
155            }
156            eff.adv_ignore = a.ignore.clone();
157        }
158        if let Some(s) = &policy.signals {
159            if let Some(v) = s.warn_on_build_rs {
160                eff.warn_on_build_rs = v;
161            }
162            if let Some(v) = s.require_review_on_build_rs {
163                eff.require_review_on_build_rs = v;
164            }
165            if let Some(v) = s.require_review_on_native_ffi {
166                eff.require_review_on_native_ffi = v;
167            }
168            if let Some(v) = s.fail_on_yanked {
169                eff.fail_on_yanked = v;
170            }
171            if let Some(v) = s.fail_on_denied_license {
172                eff.fail_on_denied_license = v;
173            }
174            if let Some(v) = s.warn_on_unknown_license {
175                eff.warn_on_unknown_license = v;
176            }
177            if let Some(v) = s.fail_on_unknown_license {
178                eff.fail_on_unknown_license = v;
179            }
180        }
181        if let Some(l) = &policy.licenses {
182            // Guard each (like the advisories branch): supplying only `allow` must
183            // NOT silently wipe the profile's default deny list (GPL/AGPL).
184            if !l.allow.is_empty() {
185                eff.license_allow = l.allow.clone();
186            }
187            if !l.deny.is_empty() {
188                eff.license_deny = l.deny.clone();
189            }
190        }
191        if let Some(a) = &policy.allow {
192            eff.allow_crates = a.crates.clone();
193        }
194        eff
195    }
196
197    fn defaults_for(profile: &str) -> Self {
198        // Common baseline; tuned per profile below.
199        let base = Self {
200            profile: profile.to_string(),
201            max_project_score: 70,
202            max_package_score: 85,
203            fail_on_delta_above: 35,
204            warn_on_delta_above: 10,
205            adv_fail_on: vec!["critical".into(), "high".into()],
206            adv_warn_on: vec!["medium".into(), "low".into()],
207            adv_ignore: vec![],
208            // build.rs presence is ubiquitous — don't warn by default; the
209            // suspicious-intent scanner is what drives the decision.
210            warn_on_build_rs: false,
211            require_review_on_build_rs: false,
212            require_review_on_native_ffi: true,
213            fail_on_yanked: true,
214            fail_on_denied_license: true,
215            warn_on_unknown_license: true,
216            fail_on_unknown_license: false,
217            license_allow: vec![],
218            license_deny: vec!["GPL-3.0".into(), "AGPL-3.0".into()],
219            allow_crates: vec![],
220        };
221        match profile {
222            "strict" => Self {
223                max_project_score: 50,
224                max_package_score: 70,
225                fail_on_delta_above: 20,
226                warn_on_delta_above: 5,
227                adv_fail_on: vec!["critical".into(), "high".into(), "medium".into()],
228                adv_warn_on: vec!["low".into()],
229                require_review_on_build_rs: true,
230                warn_on_unknown_license: false,
231                fail_on_unknown_license: true,
232                ..base
233            },
234            "permissive" => Self {
235                max_project_score: 90,
236                max_package_score: 95,
237                fail_on_delta_above: 60,
238                warn_on_delta_above: 25,
239                adv_fail_on: vec!["critical".into()],
240                adv_warn_on: vec!["high".into(), "medium".into(), "low".into()],
241                require_review_on_native_ffi: false,
242                fail_on_yanked: false,
243                license_deny: vec!["AGPL-3.0".into()],
244                ..base
245            },
246            _ => base, // "balanced" and any custom name
247        }
248    }
249}
250
251/// Evaluate the policy against the project risk, the collected signals, and an
252/// optional score delta (present for `diff`, absent for `check`).
253pub fn evaluate(
254    risk: &ProjectRisk,
255    signals: &[RiskSignal],
256    delta: Option<i32>,
257    policy: Option<&Policy>,
258) -> Result<PolicyDecision, RustinelError> {
259    let eff = Effective::from(policy);
260
261    let mut violations = Vec::new();
262    let mut warnings = Vec::new();
263    let mut review_items = Vec::new();
264    let mut ignored_advisories: Vec<String> = Vec::new();
265
266    // --- Score thresholds ---
267    if risk.score > eff.max_project_score {
268        violations.push(format!(
269            "project risk score {} exceeds threshold {}",
270            risk.score, eff.max_project_score
271        ));
272    }
273    if risk.max_package_score > eff.max_package_score {
274        if let Some(top) = risk.packages.iter().max_by_key(|p| p.score) {
275            violations.push(format!(
276                "package `{}` score {} exceeds per-package threshold {}",
277                top.package, top.score, eff.max_package_score
278            ));
279        }
280    }
281
282    // --- Diff delta ---
283    if let Some(delta) = delta {
284        if delta > eff.fail_on_delta_above {
285            violations.push(format!(
286                "risk score increased by {delta}, above fail threshold {}",
287                eff.fail_on_delta_above
288            ));
289        } else if delta > eff.warn_on_delta_above {
290            warnings.push(format!(
291                "risk score increased by {delta}, above warn threshold {}",
292                eff.warn_on_delta_above
293            ));
294        }
295    }
296
297    // --- Per-signal policy ---
298    for signal in signals {
299        let crate_name = crate_name_of(&signal.package);
300        let allowlisted = eff.allow_crates.iter().any(|c| c == crate_name);
301
302        if signal.id.starts_with("advisory_") {
303            let advisory_id = signal.id.trim_start_matches("advisory_");
304            if eff
305                .adv_ignore
306                .iter()
307                .any(|i| i.eq_ignore_ascii_case(advisory_id))
308            {
309                warnings.push(format!(
310                    "advisory {advisory_id} for `{}` is ignored by policy",
311                    signal.package
312                ));
313                ignored_advisories.push(advisory_id.to_string());
314                continue;
315            }
316            let sev = signal.severity.as_str().to_string();
317            if eff.adv_fail_on.iter().any(|s| s.eq_ignore_ascii_case(&sev)) && !allowlisted {
318                violations.push(format!("{} ({}) on `{}`", advisory_id, sev, signal.package));
319            } else if eff.adv_warn_on.iter().any(|s| s.eq_ignore_ascii_case(&sev)) || allowlisted {
320                warnings.push(format!("{} ({}) on `{}`", advisory_id, sev, signal.package));
321            }
322            continue;
323        }
324
325        // Findings downgraded to Info by the known-good baseline (or otherwise
326        // purely informational) must not drive the decision — except
327        // `license_detected`, which is Info but still feeds the deny-license check.
328        let is_baseline_info =
329            signal.severity == crate::signals::Severity::Info && signal.id != "license_detected";
330        if is_baseline_info {
331            continue;
332        }
333
334        match signal.id.as_str() {
335            // Runtime secret-exfiltration fingerprint — treat like a build-script
336            // malware signal: strict fails, otherwise always demands review.
337            "suspicious_source_exfil" => {
338                if eff.profile == "strict" && !allowlisted {
339                    violations.push(format!(
340                        "`{}` source matches a secret-exfiltration malware pattern",
341                        signal.package
342                    ));
343                } else {
344                    review_items.push(format!(
345                        "`{}` source matches a secret-exfiltration malware pattern",
346                        signal.package
347                    ));
348                }
349            }
350            // A build script with network/payload intent is a malware vector:
351            // under strict it fails, otherwise it always demands review.
352            "build_script_suspicious" => {
353                if eff.profile == "strict" && !allowlisted {
354                    violations.push(format!(
355                        "`{}` has a suspicious build script (network/payload)",
356                        signal.package
357                    ));
358                } else {
359                    review_items.push(format!(
360                        "`{}` has a suspicious build script (network/payload)",
361                        signal.package
362                    ));
363                }
364            }
365            // Likely typosquat / impersonation — always demands a human look.
366            "possible_typosquat" => {
367                if eff.profile == "strict" && !allowlisted {
368                    violations.push(format!(
369                        "`{}` looks like a typosquat of a popular crate",
370                        signal.package
371                    ));
372                } else {
373                    review_items.push(format!(
374                        "`{}` looks like a typosquat of a popular crate",
375                        signal.package
376                    ));
377                }
378            }
379            "build_script_present" => {
380                if eff.require_review_on_build_rs && !allowlisted {
381                    review_items.push(format!("`{}` ships a build script", signal.package));
382                } else if eff.warn_on_build_rs {
383                    warnings.push(format!("`{}` ships a build script", signal.package));
384                }
385            }
386            "native_ffi_detected" => {
387                if allowlisted {
388                    // explicitly trusted via allow.crates — emit nothing
389                } else if eff.require_review_on_native_ffi {
390                    review_items.push(format!("`{}` is a native/FFI dependency", signal.package));
391                } else {
392                    warnings.push(format!("`{}` is a native/FFI dependency", signal.package));
393                }
394            }
395            "license_unknown" => {
396                if eff.fail_on_unknown_license && !allowlisted {
397                    violations.push(format!("`{}` has an unknown license", signal.package));
398                } else if eff.warn_on_unknown_license {
399                    warnings.push(format!("`{}` has an unknown license", signal.package));
400                }
401            }
402            "license_detected" => {
403                if let Some(license) = license_from_signal(signal) {
404                    match license_verdict(&license, &eff.license_allow, &eff.license_deny) {
405                        LicenseVerdict::Denied => {
406                            if eff.fail_on_denied_license && !allowlisted {
407                                violations.push(format!(
408                                    "`{}` uses denied license {license}",
409                                    signal.package
410                                ));
411                            } else {
412                                warnings.push(format!(
413                                    "`{}` uses denied license {license}",
414                                    signal.package
415                                ));
416                            }
417                        }
418                        LicenseVerdict::NotAllowed => {
419                            warnings.push(format!(
420                                "`{}` license {license} is not on the allow list",
421                                signal.package
422                            ));
423                        }
424                        LicenseVerdict::Ok => {}
425                    }
426                }
427            }
428            "yanked_crate" => {
429                if allowlisted {
430                    // explicitly trusted via allow.crates — emit nothing
431                } else if eff.fail_on_yanked {
432                    violations.push(format!("`{}` is yanked", signal.package));
433                } else {
434                    warnings.push(format!("`{}` is yanked", signal.package));
435                }
436            }
437            // Explicit deny-list match: an operator's strongest control. Always a
438            // violation; the allowlist does not override an explicit deny.
439            "denied_crate" => {
440                violations.push(format!(
441                    "dependency `{}` is on the policy deny list",
442                    crate_name_of(&signal.package)
443                ));
444            }
445            // Env-gated download-and-execute (the rustdecimal pattern): a
446            // malware-class source signal — strict fails, otherwise demands review.
447            "env_gated_payload" => {
448                if eff.profile == "strict" && !allowlisted {
449                    violations.push(format!(
450                        "`{}` source gates a download-and-execute on an environment variable",
451                        signal.package
452                    ));
453                } else {
454                    review_items.push(format!(
455                        "`{}` source gates a download-and-execute on an environment variable",
456                        signal.package
457                    ));
458                }
459            }
460            // A data-exfiltration domain hard-coded in source (the faster_log
461            // pattern): malware-class — strict fails, otherwise demands review.
462            "suspicious_exfil_domain" => {
463                if eff.profile == "strict" && !allowlisted {
464                    violations.push(format!(
465                        "`{}` source references a data-exfiltration domain",
466                        signal.package
467                    ));
468                } else {
469                    review_items.push(format!(
470                        "`{}` source references a data-exfiltration domain",
471                        signal.package
472                    ));
473                }
474            }
475            // Maintainer takeover (xz / event-stream): the ownership change demands
476            // a human look; strict treats it as blocking.
477            "owners_changed" => {
478                if eff.profile == "strict" && !allowlisted {
479                    violations.push(format!(
480                        "`{}` crates.io owners changed since the trust baseline",
481                        signal.package
482                    ));
483                } else {
484                    review_items.push(format!(
485                        "`{}` crates.io owners changed since the trust baseline",
486                        signal.package
487                    ));
488                }
489            }
490            // Dependency confusion / source substitution: a trusted name from a
491            // non-crates.io source demands review; strict treats it as blocking.
492            "source_substitution" => {
493                if eff.profile == "strict" && !allowlisted {
494                    violations.push(format!(
495                        "`{}` resolves from a non-crates.io source (possible dependency confusion)",
496                        signal.package
497                    ));
498                } else {
499                    review_items.push(format!(
500                        "`{}` resolves from a non-crates.io source (possible dependency confusion)",
501                        signal.package
502                    ));
503                }
504            }
505            // Freshly published ("new == unreviewed"): the softest signal — review
506            // under strict, a warning otherwise.
507            "freshly_published" => {
508                if eff.profile == "strict" && !allowlisted {
509                    review_items.push(format!(
510                        "`{}` was published very recently (little time for review)",
511                        signal.package
512                    ));
513                } else {
514                    warnings.push(format!(
515                        "`{}` was published very recently (little time for review)",
516                        signal.package
517                    ));
518                }
519            }
520            _ => {}
521        }
522    }
523
524    dedup(&mut violations);
525    dedup(&mut warnings);
526    dedup(&mut review_items);
527    dedup(&mut ignored_advisories);
528
529    let decision = if !violations.is_empty() {
530        Decision::Fail
531    } else if !review_items.is_empty() {
532        Decision::ReviewRequired
533    } else if !warnings.is_empty() {
534        Decision::Warn
535    } else {
536        Decision::Pass
537    };
538
539    Ok(PolicyDecision {
540        decision,
541        profile: eff.profile,
542        violations,
543        warnings,
544        review_items,
545        ignored_advisories,
546    })
547}
548
549fn dedup(items: &mut Vec<String>) {
550    let mut seen = std::collections::BTreeSet::new();
551    items.retain(|i| seen.insert(i.clone()));
552}
553
554fn crate_name_of(package: &str) -> &str {
555    package.split('@').next().unwrap_or(package)
556}
557
558const LICENSE_PREFIX: &str = "declared license: ";
559
560fn license_from_signal(signal: &RiskSignal) -> Option<String> {
561    signal.evidence.iter().find_map(|e| {
562        e.summary
563            .strip_prefix(LICENSE_PREFIX)
564            .map(|s| s.to_string())
565    })
566}
567
568/// Build the canonical license-detected evidence summary (kept in sync with the
569/// parser above).
570pub fn license_summary(license: &str) -> String {
571    format!("{LICENSE_PREFIX}{license}")
572}
573
574#[derive(Debug, PartialEq, Eq)]
575enum LicenseVerdict {
576    Ok,
577    NotAllowed,
578    Denied,
579}
580
581/// Evaluate an SPDX license expression against allow/deny lists with correct
582/// boolean semantics (so `MIT OR GPL-3.0` is fine when MIT is allowed even if
583/// GPL-3.0 is denied — you can satisfy the OR with MIT).
584fn license_verdict(expr: &str, allow: &[String], deny: &[String]) -> LicenseVerdict {
585    // Match by SPDX license *family* so the deprecated bare id (`GPL-3.0`) and the
586    // modern forms (`GPL-3.0-only`, `GPL-3.0-or-later`, `GPL-3.0+`) all match each
587    // other — otherwise a `GPL-3.0` deny entry fails open against the modern spelling.
588    let contains = |list: &[String], lic: &str| {
589        let fam = license_family(lic);
590        list.iter()
591            .any(|x| license_family(x).eq_ignore_ascii_case(fam))
592    };
593
594    // Denied iff the expression cannot be satisfied while avoiding denied licenses.
595    if !deny.is_empty() && !satisfiable(expr, &|lic| !contains(deny, lic)) {
596        return LicenseVerdict::Denied;
597    }
598    // Not-allowed iff an allow list exists but the expression can't be satisfied
599    // using only allowed licenses.
600    if !allow.is_empty() && !satisfiable(expr, &|lic| contains(allow, lic)) {
601        return LicenseVerdict::NotAllowed;
602    }
603    LicenseVerdict::Ok
604}
605
606/// The SPDX license "family": the base identifier with the `+` operator and the
607/// `-only` / `-or-later` suffixes stripped, so every spelling of GPL-3.0 collapses
608/// to one key for allow/deny matching.
609fn license_family(id: &str) -> &str {
610    let id = id.strip_suffix('+').unwrap_or(id);
611    let id = id.strip_suffix("-or-later").unwrap_or(id);
612    id.strip_suffix("-only").unwrap_or(id)
613}
614
615/// True if the SPDX expression can be satisfied when each license leaf is
616/// evaluated by `pred`. Falls back to "any token satisfies pred" on parse error.
617pub(crate) fn satisfiable(expr: &str, pred: &dyn Fn(&str) -> bool) -> bool {
618    let toks = tokenize_spdx(expr);
619    if toks.is_empty() {
620        return true; // no constraint
621    }
622    let mut p = SpdxParser {
623        toks: &toks,
624        pos: 0,
625    };
626    match p.parse_expr(pred) {
627        Some(v) if p.pos == p.toks.len() => v,
628        // Malformed expression: conservative OR over the license tokens.
629        _ => toks.iter().any(|t| matches!(t, SpdxTok::Lic(l) if pred(l))),
630    }
631}
632
633#[derive(Debug, PartialEq, Eq)]
634enum SpdxTok {
635    LParen,
636    RParen,
637    And,
638    Or,
639    With,
640    Lic(String),
641}
642
643fn tokenize_spdx(expr: &str) -> Vec<SpdxTok> {
644    let mut toks = Vec::new();
645    let mut word = String::new();
646    let flush = |word: &mut String, toks: &mut Vec<SpdxTok>| {
647        if !word.is_empty() {
648            match word.as_str() {
649                "OR" => toks.push(SpdxTok::Or),
650                "AND" => toks.push(SpdxTok::And),
651                "WITH" => toks.push(SpdxTok::With),
652                _ => toks.push(SpdxTok::Lic(std::mem::take(word))),
653            }
654            word.clear();
655        }
656    };
657    for ch in expr.chars() {
658        match ch {
659            '(' => {
660                flush(&mut word, &mut toks);
661                toks.push(SpdxTok::LParen);
662            }
663            ')' => {
664                flush(&mut word, &mut toks);
665                toks.push(SpdxTok::RParen);
666            }
667            c if c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '+' || c == '_' => {
668                word.push(c);
669            }
670            _ => flush(&mut word, &mut toks),
671        }
672    }
673    flush(&mut word, &mut toks);
674    toks
675}
676
677struct SpdxParser<'a> {
678    toks: &'a [SpdxTok],
679    pos: usize,
680}
681
682impl<'a> SpdxParser<'a> {
683    fn peek(&self) -> Option<&SpdxTok> {
684        self.toks.get(self.pos)
685    }
686
687    // expr := term (OR term)*
688    fn parse_expr(&mut self, pred: &dyn Fn(&str) -> bool) -> Option<bool> {
689        let mut acc = self.parse_term(pred)?;
690        while matches!(self.peek(), Some(SpdxTok::Or)) {
691            self.pos += 1;
692            let rhs = self.parse_term(pred)?;
693            acc = acc || rhs;
694        }
695        Some(acc)
696    }
697
698    // term := factor (AND factor)*
699    fn parse_term(&mut self, pred: &dyn Fn(&str) -> bool) -> Option<bool> {
700        let mut acc = self.parse_factor(pred)?;
701        while matches!(self.peek(), Some(SpdxTok::And)) {
702            self.pos += 1;
703            let rhs = self.parse_factor(pred)?;
704            acc = acc && rhs;
705        }
706        Some(acc)
707    }
708
709    // factor := '(' expr ')' | license ('WITH' exception)?
710    fn parse_factor(&mut self, pred: &dyn Fn(&str) -> bool) -> Option<bool> {
711        match self.peek() {
712            Some(SpdxTok::LParen) => {
713                self.pos += 1;
714                let v = self.parse_expr(pred)?;
715                if matches!(self.peek(), Some(SpdxTok::RParen)) {
716                    self.pos += 1;
717                    Some(v)
718                } else {
719                    None
720                }
721            }
722            Some(SpdxTok::Lic(name)) => {
723                let v = pred(name);
724                self.pos += 1;
725                // `license WITH exception` — exception doesn't change allow/deny.
726                if matches!(self.peek(), Some(SpdxTok::With)) {
727                    self.pos += 1;
728                    if matches!(self.peek(), Some(SpdxTok::Lic(_))) {
729                        self.pos += 1;
730                    }
731                }
732                Some(v)
733            }
734            _ => None,
735        }
736    }
737}
738
739pub fn parse_policy_toml(input: &str) -> Result<Policy, RustinelError> {
740    toml::from_str(input).map_err(|e| RustinelError::InvalidPolicy(e.to_string()))
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746    use crate::risk::{PackageRisk, RiskLevel};
747    use crate::signals::{Evidence, Severity};
748
749    fn risk(score: u8, max_pkg: u8) -> ProjectRisk {
750        ProjectRisk {
751            score,
752            level: crate::risk::level_for_score(score),
753            max_package_score: max_pkg,
754            packages: vec![PackageRisk {
755                package: "x@1".into(),
756                score: max_pkg,
757                level: RiskLevel::Low,
758            }],
759        }
760    }
761
762    #[test]
763    fn fails_above_project_threshold() {
764        let policy = parse_policy_toml("[risk]\nmax_project_score = 10\n").unwrap();
765        let d = evaluate(&risk(40, 0), &[], None, Some(&policy)).unwrap();
766        assert_eq!(d.decision, Decision::Fail);
767        assert!(!d.violations.is_empty());
768    }
769
770    #[test]
771    fn passes_below_threshold() {
772        let policy = parse_policy_toml("[risk]\nmax_project_score = 90\n").unwrap();
773        let d = evaluate(&risk(40, 50), &[], None, Some(&policy)).unwrap();
774        assert_eq!(d.decision, Decision::Pass);
775    }
776
777    #[test]
778    fn native_ffi_requires_review_under_balanced() {
779        let sig = RiskSignal {
780            id: "native_ffi_detected".into(),
781            package: "openssl-sys@0.9.99".into(),
782            severity: Severity::High,
783            weight: 20,
784            confidence: 0.9,
785            evidence: vec![],
786            recommendation: String::new(),
787        };
788        let d = evaluate(&risk(20, 20), std::slice::from_ref(&sig), None, None).unwrap();
789        assert_eq!(d.decision, Decision::ReviewRequired);
790    }
791
792    #[test]
793    fn allowlisted_native_ffi_is_silent() {
794        // An explicit allow.crates entry must silence the native/FFI signal — it
795        // is exactly the situation an operator allowlists for (e.g. openssl-sys).
796        let sig = RiskSignal {
797            id: "native_ffi_detected".into(),
798            package: "openssl-sys@0.9.99".into(),
799            severity: Severity::High,
800            weight: 20,
801            confidence: 0.9,
802            evidence: vec![],
803            recommendation: String::new(),
804        };
805        let policy = parse_policy_toml("[allow]\ncrates = [\"openssl-sys\"]\n").unwrap();
806        let d = evaluate(
807            &risk(20, 20),
808            std::slice::from_ref(&sig),
809            None,
810            Some(&policy),
811        )
812        .unwrap();
813        assert_eq!(d.decision, Decision::Pass);
814        assert!(d.warnings.is_empty() && d.review_items.is_empty());
815    }
816
817    #[test]
818    fn denied_crate_signal_fails_and_allowlist_does_not_override() {
819        let sig = RiskSignal {
820            id: "denied_crate".into(),
821            package: "foo@1.0.0".into(),
822            severity: Severity::High,
823            weight: 0,
824            confidence: 1.0,
825            evidence: vec![],
826            recommendation: String::new(),
827        };
828        // Even when also allowlisted, an explicit deny wins.
829        let policy = parse_policy_toml("[allow]\ncrates = [\"foo\"]\n").unwrap();
830        let d = evaluate(&risk(0, 0), std::slice::from_ref(&sig), None, Some(&policy)).unwrap();
831        assert_eq!(d.decision, Decision::Fail);
832        assert!(d.violations.iter().any(|v| v.contains("deny list")));
833    }
834
835    #[test]
836    fn advisory_high_fails_balanced() {
837        let sig = RiskSignal {
838            id: "advisory_RUSTSEC-2099-0001".into(),
839            package: "vuln@1.0.0".into(),
840            severity: Severity::High,
841            weight: 30,
842            confidence: 1.0,
843            evidence: vec![],
844            recommendation: String::new(),
845        };
846        let d = evaluate(&risk(30, 30), std::slice::from_ref(&sig), None, None).unwrap();
847        assert_eq!(d.decision, Decision::Fail);
848    }
849
850    #[test]
851    fn ignored_advisory_downgrades_to_warning() {
852        let policy = parse_policy_toml("[advisories]\nignore = [\"RUSTSEC-2099-0001\"]\n").unwrap();
853        let sig = RiskSignal {
854            id: "advisory_RUSTSEC-2099-0001".into(),
855            package: "vuln@1.0.0".into(),
856            severity: Severity::High,
857            weight: 30,
858            confidence: 1.0,
859            evidence: vec![],
860            recommendation: String::new(),
861        };
862        let d = evaluate(
863            &risk(30, 30),
864            std::slice::from_ref(&sig),
865            None,
866            Some(&policy),
867        )
868        .unwrap();
869        assert_eq!(d.decision, Decision::Warn);
870    }
871
872    #[test]
873    fn denied_license_fails() {
874        let sig = RiskSignal {
875            id: "license_detected".into(),
876            package: "gpl-crate@1.0.0".into(),
877            severity: Severity::Info,
878            weight: 0,
879            confidence: 1.0,
880            evidence: vec![Evidence::new("manifest", license_summary("GPL-3.0"))],
881            recommendation: String::new(),
882        };
883        let d = evaluate(&risk(0, 0), std::slice::from_ref(&sig), None, None).unwrap();
884        assert_eq!(d.decision, Decision::Fail);
885    }
886
887    #[test]
888    fn spdx_expression_semantics() {
889        let deny = vec!["GPL-3.0".to_string(), "AGPL-3.0".to_string()];
890        let allow = vec![
891            "MIT".to_string(),
892            "Apache-2.0".to_string(),
893            "BSD-3-Clause".to_string(),
894        ];
895        // OR with an allowed escape is NOT denied (the FP the old code had).
896        assert_eq!(
897            license_verdict("MIT OR GPL-3.0", &[], &deny),
898            LicenseVerdict::Ok
899        );
900        // Pure denied license is denied.
901        assert_eq!(
902            license_verdict("GPL-3.0", &[], &deny),
903            LicenseVerdict::Denied
904        );
905        // AND requiring a denied license is denied (can't avoid it).
906        assert_eq!(
907            license_verdict("MIT AND GPL-3.0", &[], &deny),
908            LicenseVerdict::Denied
909        );
910        // Allow-list: satisfiable with allowed licenses.
911        assert_eq!(
912            license_verdict("MIT OR Apache-2.0", &allow, &deny),
913            LicenseVerdict::Ok
914        );
915        // Allow-list: a license not on the list -> NotAllowed.
916        assert_eq!(
917            license_verdict("WTFPL", &allow, &deny),
918            LicenseVerdict::NotAllowed
919        );
920        // Parentheses + WITH exception parse and evaluate.
921        assert_eq!(
922            license_verdict("(MIT OR Apache-2.0) AND BSD-3-Clause", &allow, &deny),
923            LicenseVerdict::Ok
924        );
925        assert_eq!(
926            license_verdict("Apache-2.0 WITH LLVM-exception", &allow, &deny),
927            LicenseVerdict::Ok
928        );
929    }
930
931    #[test]
932    fn allowlisted_crate_downgrades_advisory() {
933        let policy = parse_policy_toml("[allow]\ncrates = [\"vuln\"]\n").unwrap();
934        let sig = RiskSignal {
935            id: "advisory_RUSTSEC-2099-0001".into(),
936            package: "vuln@1.0.0".into(),
937            severity: Severity::High,
938            weight: 30,
939            confidence: 1.0,
940            evidence: vec![],
941            recommendation: String::new(),
942        };
943        let d = evaluate(
944            &risk(30, 30),
945            std::slice::from_ref(&sig),
946            None,
947            Some(&policy),
948        )
949        .unwrap();
950        assert_eq!(d.decision, Decision::Warn);
951    }
952
953    #[test]
954    fn delta_fail_threshold() {
955        let policy = parse_policy_toml("[risk]\nfail_on_delta_above = 10\n").unwrap();
956        let d = evaluate(&risk(0, 0), &[], Some(45), Some(&policy)).unwrap();
957        assert_eq!(d.decision, Decision::Fail);
958    }
959}