Skip to main content

vivacity_resolver/
policy_config.rs

1//! Port of `Composer\Policy\PolicyConfig` and the policy classes
2//! (docs/reference/policy/*.php): what `config.policy` and the legacy
3//! `config.audit` say about the three pool filters (security advisories,
4//! filter lists (malware), abandoned packages) after the global/project
5//! merge of `Config::merge`, the environment variables (`COMPOSER_POLICY`,
6//! `COMPOSER_POLICY_*_BLOCK`, `COMPOSER_NO_BLOCKING`...) and
7//! `--no-blocking`.
8//!
9//! Not ported: custom lists (`policy.<other name>`) and
10//! `audit.abandoned`/`policy.*.audit` (audit mode, no effect on blocking);
11//! the former are rejected, the latter is ignored.
12
13use serde_json::{Map, Value};
14
15use crate::constraint::{parse_constraints, Constraint};
16
17#[derive(Debug, thiserror::Error)]
18#[error("{0}")]
19pub struct PolicyError(pub String);
20
21/// A per-package ignore rule (`IgnorePackageRule`).
22#[derive(Debug, Clone)]
23pub struct IgnorePackageRule {
24    pub package_name: String,
25    pub constraint: Constraint,
26    pub reason: Option<String>,
27    pub on_block: bool,
28    pub on_audit: bool,
29}
30
31/// A per-advisory-id ignore rule (`IgnoreIdRule`).
32#[derive(Debug, Clone)]
33pub struct IgnoreIdRule {
34    pub id: String,
35    pub reason: Option<String>,
36    pub on_block: bool,
37    pub on_audit: bool,
38}
39
40/// `IgnoreSeverityRule`.
41#[derive(Debug, Clone)]
42pub struct IgnoreSeverityRule {
43    pub severity: String,
44    pub reason: Option<String>,
45    pub on_block: bool,
46    pub on_audit: bool,
47}
48
49/// Per-package rules, in declaration order (PHP array).
50pub type IgnoreMap = Vec<(String, Vec<IgnorePackageRule>)>;
51
52#[derive(Debug, Clone)]
53pub struct AdvisoriesPolicy {
54    pub block: bool,
55    pub ignore: IgnoreMap,
56    pub ignore_id: Vec<IgnoreIdRule>,
57    pub ignore_severity: Vec<IgnoreSeverityRule>,
58}
59
60#[derive(Debug, Clone)]
61pub struct MalwarePolicy {
62    pub block: bool,
63    /// `all`, `update` or `install`.
64    pub block_scope: String,
65    pub ignore: IgnoreMap,
66    pub ignore_source: Vec<String>,
67}
68
69#[derive(Debug, Clone)]
70pub struct AbandonedPolicy {
71    pub block: bool,
72    pub ignore: IgnoreMap,
73}
74
75/// `IgnoreUnreachable`: an unreachable repository is ignored (warning) or
76/// fatal, per operation.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub struct IgnoreUnreachable {
79    pub audit: bool,
80    pub install: bool,
81    pub update: bool,
82}
83
84impl IgnoreUnreachable {
85    pub fn for_block_scope(&self, scope: &str) -> bool {
86        if scope == "install" {
87            self.install
88        } else {
89            self.update
90        }
91    }
92}
93
94#[derive(Debug, Clone)]
95pub struct PolicyConfig {
96    /// `policy: false` (or `COMPOSER_POLICY=0`): everything is disabled.
97    pub enabled: bool,
98    pub advisories: AdvisoriesPolicy,
99    pub malware: MalwarePolicy,
100    pub abandoned: AbandonedPolicy,
101    /// Names of the declared custom lists (not ported).
102    pub custom_lists: Vec<String>,
103    pub ignore_unreachable: IgnoreUnreachable,
104}
105
106const NON_LIST_KEYS: &[&str] = &["ignore-unreachable"];
107const BUILTIN_LIST_NAMES: &[&str] = &["advisories", "malware", "abandoned"];
108
109/// `Platform::getBoolEnv`: `0`/`1`/`false`/`true`/`off`/`on`, empty =
110/// absent, any other value = error.
111pub fn bool_env(name: &str) -> Result<Option<bool>, PolicyError> {
112    match std::env::var(name) {
113        Ok(v) if !v.is_empty() => match v.as_str() {
114            "1" | "true" | "on" => Ok(Some(true)),
115            "0" | "false" | "off" => Ok(Some(false)),
116            other => Err(PolicyError(format!(
117                "Invalid value for {name}: {other}. Expected 0, 1, false, true, off, or on."
118            ))),
119        },
120        _ => Ok(None),
121    }
122}
123
124/// The merged value of `config.policy` and `config.audit` (`Config`
125/// defaults, then the global config, then the project: `Config::merge`).
126#[derive(Debug, Clone)]
127pub struct RawPolicyConfig {
128    /// `true`, `false` or an object.
129    pub policy: Value,
130    pub audit: Value,
131}
132
133impl Default for RawPolicyConfig {
134    fn default() -> Self {
135        RawPolicyConfig {
136            policy: Value::Bool(true),
137            audit: serde_json::json!({"ignore": [], "abandoned": "fail"}),
138        }
139    }
140}
141
142impl RawPolicyConfig {
143    /// `Config::merge` for the `audit` and `policy` keys of a `config`.
144    pub fn merge(&mut self, config: &Map<String, Value>) {
145        if let Some(val) = config.get("audit") {
146            let current_ignores = self
147                .audit
148                .get("ignore")
149                .cloned()
150                .unwrap_or(Value::Array(Vec::new()));
151            let mut merged = php_array_merge(&self.audit, val);
152            let incoming = val
153                .get("ignore")
154                .cloned()
155                .unwrap_or(Value::Array(Vec::new()));
156            if let Value::Object(m) = &mut merged {
157                m.insert(
158                    "ignore".into(),
159                    php_array_merge(&current_ignores, &incoming),
160                );
161            }
162            self.audit = merged;
163        }
164        if let Some(val) = config.get("policy") {
165            let val = if val == &Value::Bool(true) {
166                Value::Object(Map::new())
167            } else {
168                val.clone()
169            };
170            match val {
171                Value::Bool(false) => self.policy = Value::Bool(false),
172                Value::Object(lists) => {
173                    let mut current = match &self.policy {
174                        Value::Object(m) => m.clone(),
175                        _ => Map::new(),
176                    };
177                    for (list_name, list_config) in lists {
178                        if NON_LIST_KEYS.contains(&list_name.as_str()) {
179                            current.insert(list_name, list_config);
180                            continue;
181                        }
182                        let list_config = if list_config == Value::Bool(true) {
183                            Value::Object(Map::new())
184                        } else {
185                            list_config
186                        };
187                        let existing = match current.get(&list_name) {
188                            Some(Value::Bool(true)) => Some(Value::Object(Map::new())),
189                            Some(v) => Some(v.clone()),
190                            None => None,
191                        };
192                        let merged = match (&existing, &list_config) {
193                            (_, Value::Bool(false)) => Value::Bool(false),
194                            (None | Some(Value::Bool(false)), _) => list_config.clone(),
195                            (Some(Value::Object(e)), Value::Object(i)) => {
196                                let mut m = php_array_merge(
197                                    &Value::Object(e.clone()),
198                                    &Value::Object(i.clone()),
199                                );
200                                for inner in
201                                    ["ignore", "ignore-id", "ignore-severity", "ignore-source"]
202                                {
203                                    if let (Some(ei), Some(ii)) = (e.get(inner), i.get(inner)) {
204                                        if is_php_array(ei) && is_php_array(ii) {
205                                            if let Value::Object(mm) = &mut m {
206                                                mm.insert(inner.into(), php_array_merge(ei, ii));
207                                            }
208                                        }
209                                    }
210                                }
211                                m
212                            }
213                            _ => list_config.clone(),
214                        };
215                        current.insert(list_name, merged);
216                    }
217                    self.policy = Value::Object(current);
218                }
219                _ => {}
220            }
221        }
222    }
223
224    /// `Config::get('policy')`: `COMPOSER_POLICY=0` disables, `=1`
225    /// re-enables a `false`.
226    pub fn effective_policy(&self) -> Result<Value, PolicyError> {
227        Ok(match bool_env("COMPOSER_POLICY")? {
228            Some(false) => Value::Bool(false),
229            Some(true) if self.policy == Value::Bool(false) => Value::Bool(true),
230            _ => self.policy.clone(),
231        })
232    }
233}
234
235fn is_php_array(v: &Value) -> bool {
236    matches!(v, Value::Array(_) | Value::Object(_))
237}
238
239/// `array_merge($a, $b)` on decoded arrays: the string keys of `b` replace
240/// those of `a` (in place), list entries are appended at the end. A result
241/// list without any string key stays a list.
242fn php_array_merge(a: &Value, b: &Value) -> Value {
243    fn entries(v: &Value) -> Vec<(Option<String>, Value)> {
244        match v {
245            Value::Array(items) => items.iter().map(|x| (None, x.clone())).collect(),
246            Value::Object(m) => m
247                .iter()
248                .map(|(k, x)| {
249                    // A canonical numeric key is a PHP integer.
250                    let is_int = k == "0"
251                        || (k.starts_with(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
252                            && k.bytes().all(|c| c.is_ascii_digit()));
253                    (if is_int { None } else { Some(k.clone()) }, x.clone())
254                })
255                .collect(),
256            _ => Vec::new(),
257        }
258    }
259    let mut out: Vec<(Option<String>, Value)> = Vec::new();
260    for (k, v) in entries(a).into_iter().chain(entries(b)) {
261        match &k {
262            Some(key) => match out.iter_mut().find(|(ok, _)| ok.as_deref() == Some(key)) {
263                Some(slot) => slot.1 = v,
264                None => out.push((k, v)),
265            },
266            None => out.push((None, v)),
267        }
268    }
269    if out.iter().all(|(k, _)| k.is_none()) {
270        return Value::Array(out.into_iter().map(|(_, v)| v).collect());
271    }
272    let mut m = Map::new();
273    let mut idx = 0;
274    for (k, v) in out {
275        match k {
276            Some(key) => {
277                m.insert(key, v);
278            }
279            None => {
280                m.insert(idx.to_string(), v);
281                idx += 1;
282            }
283        }
284    }
285    Value::Object(m)
286}
287
288/// `is_int($key)` of a decoded array: list, or canonical numeric key.
289fn php_entries(v: &Value) -> Vec<(Option<String>, Value)> {
290    match v {
291        Value::Array(items) => items.iter().map(|x| (None, x.clone())).collect(),
292        Value::Object(m) => m
293            .iter()
294            .map(|(k, x)| {
295                let is_int = k == "0"
296                    || (k.starts_with(['1', '2', '3', '4', '5', '6', '7', '8', '9'])
297                        && k.bytes().all(|c| c.is_ascii_digit()));
298                (if is_int { None } else { Some(k.clone()) }, x.clone())
299            })
300            .collect(),
301        _ => Vec::new(),
302    }
303}
304
305fn php_truthy(v: &Value) -> bool {
306    match v {
307        Value::Null => false,
308        Value::Bool(b) => *b,
309        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
310        Value::String(s) => !(s.is_empty() || s == "0"),
311        Value::Array(a) => !a.is_empty(),
312        Value::Object(m) => !m.is_empty(),
313    }
314}
315
316fn opt_str(v: Option<&Value>) -> Option<String> {
317    v.and_then(Value::as_str).map(str::to_owned)
318}
319
320/// `(bool) ($config['x'] ?? $default)`.
321fn bool_or(m: &Map<String, Value>, key: &str, default: bool) -> bool {
322    m.get(key).map(php_truthy).unwrap_or(default)
323}
324
325/// `parseLegacySingleIgnore`.
326fn legacy_single(
327    key: &Option<String>,
328    value: &Value,
329) -> Result<(Option<String>, bool, bool), PolicyError> {
330    let mut reason = None;
331    let mut on_block = true;
332    let mut on_audit = true;
333    if key.is_none() && value.is_string() {
334        // list entry: no reason
335    } else if let Some(s) = value.as_str() {
336        reason = Some(s.to_owned());
337    } else if let Value::Object(v) = value {
338        let apply = v.get("apply").and_then(Value::as_str).unwrap_or("all");
339        reason = opt_str(v.get("reason"));
340        if !["audit", "block", "all"].contains(&apply) {
341            return Err(PolicyError(format!(
342                "Invalid 'apply' value for '{}': {apply}. Expected 'audit', 'block', or 'all'.",
343                key.clone().unwrap_or_default()
344            )));
345        }
346        on_block = apply == "block" || apply == "all";
347        on_audit = apply == "audit" || apply == "all";
348    }
349    Ok((reason, on_block, on_audit))
350}
351
352/// `ListPolicyConfig::parseLegacyAuditIgnore`: `audit.ignore` mixes ids
353/// and package names (a `/` makes it a name).
354fn parse_legacy_audit_ignore(
355    config: &Value,
356) -> Result<(IgnoreMap, Vec<IgnoreIdRule>), PolicyError> {
357    let mut packages: IgnoreMap = Vec::new();
358    let mut ids = Vec::new();
359    for (key, value) in php_entries(config) {
360        let id = match &key {
361            Some(k) => k.clone(),
362            None => php_to_string(&value),
363        };
364        let (reason, on_block, on_audit) = legacy_single(&key, &value)?;
365        if id.contains('/') {
366            push_rule(
367                &mut packages,
368                IgnorePackageRule {
369                    package_name: id,
370                    constraint: Constraint::MatchAll,
371                    reason,
372                    on_block,
373                    on_audit,
374                },
375            );
376        } else {
377            ids.push(IgnoreIdRule {
378                id,
379                reason,
380                on_block,
381                on_audit,
382            });
383        }
384    }
385    Ok((packages, ids))
386}
387
388/// `parseLegacyIgnoreWithApply` (abandoned packages).
389fn parse_legacy_ignore_with_apply(config: &Value) -> Result<IgnoreMap, PolicyError> {
390    let mut out: IgnoreMap = Vec::new();
391    for (key, value) in php_entries(config) {
392        let name = match &key {
393            Some(k) => k.clone(),
394            None => php_to_string(&value),
395        };
396        let (reason, on_block, on_audit) = legacy_single(&key, &value)?;
397        // `$result[$packageName] = [rule]`: a single rule per name.
398        out.retain(|(n, _)| *n != name);
399        out.push((
400            name.clone(),
401            vec![IgnorePackageRule {
402                package_name: name,
403                constraint: Constraint::MatchAll,
404                reason,
405                on_block,
406                on_audit,
407            }],
408        ));
409    }
410    Ok(out)
411}
412
413fn php_to_string(v: &Value) -> String {
414    match v {
415        Value::String(s) => s.clone(),
416        Value::Number(n) => n.to_string(),
417        Value::Bool(true) => "1".into(),
418        _ => String::new(),
419    }
420}
421
422fn push_rule(map: &mut IgnoreMap, rule: IgnorePackageRule) {
423    match map.iter_mut().find(|(n, _)| *n == rule.package_name) {
424        Some((_, rules)) => rules.push(rule),
425        None => map.push((rule.package_name.clone(), vec![rule])),
426    }
427}
428
429/// `IgnorePackageRule::parseIgnoreMap`.
430fn parse_ignore_map(config: &Value) -> Result<IgnoreMap, PolicyError> {
431    let mut rules: IgnoreMap = Vec::new();
432    let from_rule_object =
433        |name: &str, v: &Map<String, Value>| -> Result<IgnorePackageRule, PolicyError> {
434            let constraint = match v.get("constraint") {
435                Some(c) => {
436                    parse_constraints(&php_to_string(c))
437                        .map_err(|e| PolicyError(e.to_string()))?
438                        .constraint
439                }
440                None => Constraint::MatchAll,
441            };
442            Ok(IgnorePackageRule {
443                package_name: name.to_owned(),
444                constraint,
445                reason: opt_str(v.get("reason")),
446                on_block: v.get("on-block").map(php_truthy).unwrap_or(true),
447                on_audit: v.get("on-audit").map(php_truthy).unwrap_or(true),
448            })
449        };
450    for (key, value) in php_entries(config) {
451        match (&key, &value) {
452            (Some(k), Value::Null) => push_rule(
453                &mut rules,
454                IgnorePackageRule {
455                    package_name: k.clone(),
456                    constraint: Constraint::MatchAll,
457                    reason: None,
458                    on_block: true,
459                    on_audit: true,
460                },
461            ),
462            (Some(k), Value::String(s)) => push_rule(
463                &mut rules,
464                IgnorePackageRule {
465                    package_name: k.clone(),
466                    constraint: Constraint::MatchAll,
467                    reason: Some(s.clone()),
468                    on_block: true,
469                    on_audit: true,
470                },
471            ),
472            (None, Value::String(s)) => push_rule(
473                &mut rules,
474                IgnorePackageRule {
475                    package_name: s.clone(),
476                    constraint: Constraint::MatchAll,
477                    reason: None,
478                    on_block: true,
479                    on_audit: true,
480                },
481            ),
482            (Some(k), Value::Array(list)) => {
483                // `isset($value[0])` is false for `[]`: an empty rule object.
484                if list.is_empty() {
485                    let r = from_rule_object(k, &Map::new())?;
486                    push_rule(&mut rules, r);
487                }
488                for rule in list {
489                    let Value::Object(o) = rule else {
490                        return Err(PolicyError(format!(
491                            "Invalid ignore rule for \"{k}\": expected an object, got {}.",
492                            php_type(rule)
493                        )));
494                    };
495                    let r = from_rule_object(k, o)?;
496                    push_rule(&mut rules, r);
497                }
498            }
499            (Some(k), Value::Object(o)) if o.contains_key("0") => {
500                for rule in o.values() {
501                    let Value::Object(ro) = rule else {
502                        return Err(PolicyError(format!(
503                            "Invalid ignore rule for \"{k}\": expected an object, got {}.",
504                            php_type(rule)
505                        )));
506                    };
507                    let r = from_rule_object(k, ro)?;
508                    push_rule(&mut rules, r);
509                }
510            }
511            (Some(k), Value::Object(o)) => {
512                let r = from_rule_object(k, o)?;
513                push_rule(&mut rules, r);
514            }
515            (k, v) => {
516                return Err(PolicyError(format!(
517                    "Invalid ignore entry at key \"{}\": value of type {} is not a supported shape. Expected null, a reason string, a rule object, or a list of rule objects.",
518                    k.clone().unwrap_or_default(),
519                    php_type(v)
520                )))
521            }
522        }
523    }
524    Ok(rules)
525}
526
527fn php_type(v: &Value) -> &'static str {
528    match v {
529        Value::Null => "null",
530        Value::Bool(_) => "bool",
531        Value::Number(n) if n.is_f64() => "float",
532        Value::Number(_) => "int",
533        Value::String(_) => "string",
534        Value::Array(_) | Value::Object(_) => "array",
535    }
536}
537
538/// `IgnoreIdRule::parseIgnoreIdMap`.
539fn parse_ignore_id_map(config: &Value) -> Result<Vec<IgnoreIdRule>, PolicyError> {
540    let mut rules: Vec<IgnoreIdRule> = Vec::new();
541    let mut set = |rule: IgnoreIdRule| match rules.iter_mut().find(|r| r.id == rule.id) {
542        Some(slot) => *slot = rule,
543        None => rules.push(rule),
544    };
545    for (key, value) in php_entries(config) {
546        match (&key, &value) {
547            (None, Value::String(s)) => set(IgnoreIdRule {
548                id: s.clone(),
549                reason: None,
550                on_block: true,
551                on_audit: true,
552            }),
553            (None, other) => {
554                return Err(PolicyError(format!(
555                    "Invalid ignore-id entry: expected an advisory ID string, got {}.",
556                    php_type(other)
557                )))
558            }
559            (Some(k), Value::Null) => set(IgnoreIdRule {
560                id: k.clone(),
561                reason: None,
562                on_block: true,
563                on_audit: true,
564            }),
565            (Some(k), Value::String(s)) => set(IgnoreIdRule {
566                id: k.clone(),
567                reason: Some(s.clone()),
568                on_block: true,
569                on_audit: true,
570            }),
571            (Some(k), Value::Object(o)) => set(IgnoreIdRule {
572                id: k.clone(),
573                reason: opt_str(o.get("reason")),
574                on_block: o.get("on-block").map(php_truthy).unwrap_or(true),
575                on_audit: o.get("on-audit").map(php_truthy).unwrap_or(true),
576            }),
577            (Some(k), other) => {
578                return Err(PolicyError(format!(
579                    "Invalid ignore-id entry for \"{k}\": value of type {} is not a supported shape. Expected null, a reason string, or a rule object.",
580                    php_type(other)
581                )))
582            }
583        }
584    }
585    Ok(rules)
586}
587
588/// `AdvisoriesPolicyConfig::parseLegacySeverityWithApply`: list of
589/// severities, or map severity -> reason / `{apply, reason}`.
590fn parse_legacy_ignore_severity(config: &Value) -> Result<Vec<IgnoreSeverityRule>, PolicyError> {
591    let mut rules: Vec<IgnoreSeverityRule> = Vec::new();
592    for (key, value) in php_entries(config) {
593        let severity = match &key {
594            Some(k) => k.clone(),
595            None => php_to_string(&value),
596        };
597        let (reason, on_block, on_audit) = legacy_single(&key, &value)?;
598        rules.retain(|r| r.severity != severity);
599        rules.push(IgnoreSeverityRule {
600            severity,
601            reason,
602            on_block,
603            on_audit,
604        });
605    }
606    Ok(rules)
607}
608
609/// `IgnoreSeverityRule::parseIgnoreSeverityMap` and the legacy form
610/// (`audit.ignore-severity`: list of severities or map severity -> reason).
611fn parse_ignore_severity(config: &Value) -> Result<Vec<IgnoreSeverityRule>, PolicyError> {
612    let mut rules: Vec<IgnoreSeverityRule> = Vec::new();
613    for (key, value) in php_entries(config) {
614        let (severity, reason, on_block, on_audit) = match (&key, &value) {
615            (None, Value::String(s)) => (s.clone(), None, true, true),
616            (Some(k), Value::Null) => (k.clone(), None, true, true),
617            (Some(k), Value::String(s)) => (k.clone(), Some(s.clone()), true, true),
618            (Some(k), Value::Object(o)) => (
619                k.clone(),
620                opt_str(o.get("reason")),
621                o.get("on-block").map(php_truthy).unwrap_or(true),
622                o.get("on-audit").map(php_truthy).unwrap_or(true),
623            ),
624            (k, other) => {
625                return Err(PolicyError(format!(
626                "Invalid ignore-severity entry \"{}\": value of type {} is not a supported shape.",
627                k.clone().unwrap_or_default(),
628                php_type(other)
629            )))
630            }
631        };
632        rules.retain(|r| r.severity != severity);
633        rules.push(IgnoreSeverityRule {
634            severity,
635            reason,
636            on_block,
637            on_audit,
638        });
639    }
640    Ok(rules)
641}
642
643impl PolicyConfig {
644    /// `PolicyConfig::fromConfig` + the environment variables.
645    pub fn from_raw(raw: &RawPolicyConfig) -> Result<PolicyConfig, PolicyError> {
646        let policy = raw.effective_policy()?;
647        if policy == Value::Bool(false) {
648            return Ok(PolicyConfig {
649                enabled: false,
650                advisories: AdvisoriesPolicy {
651                    block: false,
652                    ignore: Vec::new(),
653                    ignore_id: Vec::new(),
654                    ignore_severity: Vec::new(),
655                },
656                malware: MalwarePolicy {
657                    block: false,
658                    block_scope: "all".into(),
659                    ignore: Vec::new(),
660                    ignore_source: Vec::new(),
661                },
662                abandoned: AbandonedPolicy {
663                    block: false,
664                    ignore: Vec::new(),
665                },
666                custom_lists: Vec::new(),
667                ignore_unreachable: IgnoreUnreachable {
668                    audit: true,
669                    install: true,
670                    update: true,
671                },
672            });
673        }
674        let policy_cfg = match &policy {
675            Value::Object(m) => m.clone(),
676            _ => Map::new(),
677        };
678        let audit_cfg = match &raw.audit {
679            Value::Object(m) => m.clone(),
680            _ => Map::new(),
681        };
682        let empty_audit = audit_cfg.is_empty();
683        let empty = Value::Array(Vec::new());
684
685        // AdvisoriesPolicyConfig::fromRawConfig
686        let mut advisories = if !policy_cfg.contains_key("advisories") && !empty_audit {
687            let (ignore, ignore_id) =
688                parse_legacy_audit_ignore(audit_cfg.get("ignore").unwrap_or(&empty))?;
689            AdvisoriesPolicy {
690                block: bool_or(&audit_cfg, "block-insecure", true),
691                ignore,
692                ignore_id,
693                ignore_severity: parse_legacy_ignore_severity(
694                    audit_cfg.get("ignore-severity").unwrap_or(&empty),
695                )?,
696            }
697        } else {
698            match policy_cfg.get("advisories") {
699                Some(Value::Bool(false)) => AdvisoriesPolicy {
700                    block: false,
701                    ignore: Vec::new(),
702                    ignore_id: Vec::new(),
703                    ignore_severity: Vec::new(),
704                },
705                other => {
706                    let cfg = match other {
707                        Some(Value::Object(m)) => m.clone(),
708                        _ => Map::new(),
709                    };
710                    AdvisoriesPolicy {
711                        block: bool_or(&cfg, "block", true),
712                        ignore: parse_ignore_map(cfg.get("ignore").unwrap_or(&empty))?,
713                        ignore_id: parse_ignore_id_map(cfg.get("ignore-id").unwrap_or(&empty))?,
714                        ignore_severity: parse_ignore_severity(
715                            cfg.get("ignore-severity").unwrap_or(&empty),
716                        )?,
717                    }
718                }
719            }
720        };
721        // MalwarePolicyConfig::fromRawConfig
722        let mut malware = match policy_cfg.get("malware") {
723            Some(Value::Bool(false)) => MalwarePolicy {
724                block: false,
725                block_scope: "all".into(),
726                ignore: Vec::new(),
727                ignore_source: Vec::new(),
728            },
729            other => {
730                let cfg = match other {
731                    Some(Value::Object(m)) => m.clone(),
732                    _ => Map::new(),
733                };
734                MalwarePolicy {
735                    block: bool_or(&cfg, "block", true),
736                    block_scope: opt_str(cfg.get("block-scope")).unwrap_or_else(|| "all".into()),
737                    ignore: parse_ignore_map(cfg.get("ignore").unwrap_or(&empty))?,
738                    ignore_source: cfg
739                        .get("ignore-source")
740                        .map(|v| {
741                            php_entries(v)
742                                .into_iter()
743                                .map(|(_, x)| php_to_string(&x))
744                                .collect()
745                        })
746                        .unwrap_or_default(),
747                }
748            }
749        };
750        // AbandonedPolicyConfig::fromRawConfig
751        let mut abandoned = if !policy_cfg.contains_key("abandoned") && !empty_audit {
752            AbandonedPolicy {
753                block: bool_or(&audit_cfg, "block-abandoned", false),
754                ignore: parse_legacy_ignore_with_apply(
755                    audit_cfg.get("ignore-abandoned").unwrap_or(&empty),
756                )?,
757            }
758        } else {
759            match policy_cfg.get("abandoned") {
760                Some(Value::Bool(false)) => AbandonedPolicy {
761                    block: false,
762                    ignore: Vec::new(),
763                },
764                other => {
765                    let cfg = match other {
766                        Some(Value::Object(m)) => m.clone(),
767                        _ => Map::new(),
768                    };
769                    AbandonedPolicy {
770                        block: bool_or(&cfg, "block", false),
771                        ignore: parse_ignore_map(cfg.get("ignore").unwrap_or(&empty))?,
772                    }
773                }
774            }
775        };
776        let custom_lists: Vec<String> = policy_cfg
777            .keys()
778            .filter(|k| {
779                !BUILTIN_LIST_NAMES.contains(&k.as_str()) && !NON_LIST_KEYS.contains(&k.as_str())
780            })
781            .cloned()
782            .collect();
783        let ignore_unreachable = if let Some(v) = policy_cfg.get("ignore-unreachable") {
784            match v {
785                Value::Array(list) => {
786                    let has = |s: &str| list.iter().any(|x| x.as_str() == Some(s));
787                    IgnoreUnreachable {
788                        audit: has("audit"),
789                        install: has("install"),
790                        update: has("update"),
791                    }
792                }
793                other if php_truthy(other) => IgnoreUnreachable {
794                    audit: true,
795                    install: true,
796                    update: true,
797                },
798                _ => IgnoreUnreachable {
799                    audit: false,
800                    install: false,
801                    update: false,
802                },
803            }
804        } else if audit_cfg.get("ignore-unreachable").is_some_and(php_truthy) {
805            IgnoreUnreachable {
806                audit: true,
807                install: false,
808                update: false,
809            }
810        } else {
811            IgnoreUnreachable {
812                audit: false,
813                install: true,
814                update: true,
815            }
816        };
817        // `COMPOSER_AUDIT_ABANDONED`: no effect on blocking, but an invalid
818        // value is fatal in Composer.
819        if let Ok(v) = std::env::var("COMPOSER_AUDIT_ABANDONED") {
820            if !["ignore", "report", "fail"].contains(&v.as_str()) {
821                return Err(PolicyError(format!(
822                    "Invalid value for COMPOSER_AUDIT_ABANDONED: {v}. Expected one of ignore, report, fail."
823                )));
824            }
825        }
826        if let Some(b) = bool_env("COMPOSER_POLICY_ADVISORIES_BLOCK")? {
827            advisories.block = b;
828        }
829        if let Some(b) = bool_env("COMPOSER_POLICY_MALWARE_BLOCK")? {
830            malware.block = b;
831        }
832        let abandoned_env = match bool_env("COMPOSER_POLICY_ABANDONED_BLOCK")? {
833            Some(b) => Some(b),
834            None => bool_env("COMPOSER_SECURITY_BLOCKING_ABANDONED")?,
835        };
836        if let Some(b) = abandoned_env {
837            abandoned.block = b;
838        }
839        Ok(PolicyConfig {
840            enabled: true,
841            advisories,
842            malware,
843            abandoned,
844            custom_lists,
845            ignore_unreachable,
846        })
847    }
848
849    /// `BaseCommand::createPolicyConfig`: `--no-blocking`,
850    /// `--no-security-blocking`, `COMPOSER_NO_BLOCKING`,
851    /// `COMPOSER_NO_SECURITY_BLOCKING` -> `withBlockingDisabled`.
852    pub fn apply_no_blocking(&mut self, option: bool) -> Result<(), PolicyError> {
853        let no_blocking = option
854            || bool_env("COMPOSER_NO_BLOCKING")?.unwrap_or(false)
855            || bool_env("COMPOSER_NO_SECURITY_BLOCKING")?.unwrap_or(false);
856        if no_blocking {
857            self.advisories.block = false;
858            self.malware.block = false;
859            self.abandoned.block = false;
860        }
861        Ok(())
862    }
863
864    /// `ListPolicyConfig::shouldBlock` for the malware list.
865    pub fn malware_blocks(&self, scope: &str) -> bool {
866        if !self.malware.block {
867            return false;
868        }
869        match self.malware.block_scope.as_str() {
870            "all" => true,
871            s => s == scope,
872        }
873    }
874}
875
876/// `AdvisoriesPolicyConfig::getIgnoreListForOperation('block')`: ids then
877/// names -> reason (a PHP map: `array_key_exists`).
878pub fn advisory_ignore_list_for_block(p: &AdvisoriesPolicy) -> Vec<(String, Option<String>)> {
879    let mut out: Vec<(String, Option<String>)> = Vec::new();
880    let mut set = |key: String, reason: Option<String>| {
881        match out.iter_mut().find(|(k, _)| *k == key) {
882            // `mergeReason`: the first known reason is kept if the new one
883            // is empty.
884            Some(slot) => {
885                if reason.is_some() {
886                    slot.1 = reason;
887                }
888            }
889            None => out.push((key, reason)),
890        }
891    };
892    for r in &p.ignore_id {
893        if r.on_block {
894            set(r.id.clone(), r.reason.clone());
895        }
896    }
897    for (name, rules) in &p.ignore {
898        for r in rules {
899            if r.on_block {
900                set(name.clone(), r.reason.clone());
901            }
902        }
903    }
904    out
905}
906
907/// `getIgnoreSeverityForOperation('block')`.
908pub fn advisory_ignore_severity_for_block(p: &AdvisoriesPolicy) -> Vec<(String, Option<String>)> {
909    p.ignore_severity
910        .iter()
911        .filter(|r| r.on_block)
912        .map(|r| (r.severity.clone(), r.reason.clone()))
913        .collect()
914}
915
916/// `getFlatIgnoreForOperation('block')` of a list (abandoned, malware):
917/// name -> reason.
918pub fn flat_ignore_for_block(map: &IgnoreMap) -> Vec<(String, Option<String>)> {
919    let mut out: Vec<(String, Option<String>)> = Vec::new();
920    for (name, rules) in map {
921        for r in rules {
922            if r.on_block {
923                match out.iter_mut().find(|(k, _)| k == name) {
924                    Some(slot) => {
925                        if r.reason.is_some() {
926                            slot.1 = r.reason.clone();
927                        }
928                    }
929                    None => out.push((name.clone(), r.reason.clone())),
930                }
931            }
932        }
933    }
934    out
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[test]
942    fn defaults_block_advisories_and_malware_only() {
943        let p = PolicyConfig::from_raw(&RawPolicyConfig::default()).expect("policy");
944        assert!(p.enabled);
945        assert!(p.advisories.block);
946        assert!(p.malware.block);
947        assert!(!p.abandoned.block);
948        assert_eq!(
949            p.ignore_unreachable,
950            IgnoreUnreachable {
951                audit: false,
952                install: true,
953                update: true
954            }
955        );
956    }
957
958    #[test]
959    fn legacy_audit_config_is_the_default_path() {
960        let mut raw = RawPolicyConfig::default();
961        let cfg: Map<String, Value> = serde_json::from_str(
962            r#"{"audit": {"ignore": ["CVE-2024-1", "acme/lib"], "block-insecure": false, "block-abandoned": true, "ignore-abandoned": {"old/pkg": "meh"}}}"#,
963        )
964        .expect("json");
965        raw.merge(&cfg);
966        let p = PolicyConfig::from_raw(&raw).expect("policy");
967        assert!(!p.advisories.block);
968        assert_eq!(p.advisories.ignore_id[0].id, "CVE-2024-1");
969        assert_eq!(p.advisories.ignore[0].0, "acme/lib");
970        assert!(p.abandoned.block);
971        assert_eq!(p.abandoned.ignore[0].1[0].reason.as_deref(), Some("meh"));
972    }
973
974    #[test]
975    fn policy_merge_global_then_project() {
976        let mut raw = RawPolicyConfig::default();
977        let global: Map<String, Value> = serde_json::from_str(
978            r#"{"policy": {"advisories": {"ignore": {"a/b": null}}, "malware": {"ignore-source": ["x"]}}}"#,
979        )
980        .expect("json");
981        let project: Map<String, Value> = serde_json::from_str(
982            r#"{"policy": {"advisories": {"ignore": {"c/d": "why"}, "block": false}, "abandoned": true, "malware": false}}"#,
983        )
984        .expect("json");
985        raw.merge(&global);
986        raw.merge(&project);
987        let p = PolicyConfig::from_raw(&raw).expect("policy");
988        assert!(!p.advisories.block);
989        let names: Vec<&str> = p
990            .advisories
991            .ignore
992            .iter()
993            .map(|(n, _)| n.as_str())
994            .collect();
995        assert_eq!(names, ["a/b", "c/d"]);
996        assert!(!p.malware.block);
997        assert!(!p.abandoned.block);
998        assert!(p.custom_lists.is_empty());
999    }
1000
1001    #[test]
1002    fn audit_ignore_lists_concatenate() {
1003        let mut raw = RawPolicyConfig::default();
1004        let g: Map<String, Value> =
1005            serde_json::from_str(r#"{"audit": {"ignore": ["CVE-1"]}}"#).expect("json");
1006        let p: Map<String, Value> =
1007            serde_json::from_str(r#"{"audit": {"ignore": {"CVE-2": "r"}}}"#).expect("json");
1008        raw.merge(&g);
1009        raw.merge(&p);
1010        let cfg = PolicyConfig::from_raw(&raw).expect("policy");
1011        let ids: Vec<&str> = cfg
1012            .advisories
1013            .ignore_id
1014            .iter()
1015            .map(|r| r.id.as_str())
1016            .collect();
1017        assert_eq!(ids, ["CVE-1", "CVE-2"]);
1018    }
1019}