Skip to main content

vivacity_resolver/
pool_filters.rs

1//! The filters `PoolBuilder::buildPool` applies to the pool before the
2//! optimizer: `SecurityAdvisoryPoolFilter` (security advisories and
3//! abandoned packages) then `FilterListPoolFilter` (filter lists, with
4//! Packagist's malware list first), driven by
5//! [`crate::policy_config::PolicyConfig`].
6//!
7//! Versions removed by a list are kept in `Pool::filter_list_removed`: the
8//! rule generator and the solver need them. Versions removed because of an
9//! advisory only serve Composer's explanations (not ported) and are not
10//! kept.
11
12use std::collections::BTreeMap;
13
14use pcre2::bytes::Regex;
15
16use crate::constraint::{Constraint, Op};
17use crate::package::Package;
18use crate::platform::is_platform_package;
19use crate::policy_config::{
20    advisory_ignore_list_for_block, advisory_ignore_severity_for_block, flat_ignore_for_block,
21    IgnoreMap, PolicyConfig,
22};
23use crate::pool::{Pool, Repository, Request};
24use crate::repository::{AdvisoriesByName, Advisory, ComposerRepository, FilterEntry};
25
26#[derive(Debug, thiserror::Error)]
27#[error("{0}")]
28pub struct FilterError(pub String);
29
30/// `BasePackage::packageNamesToRegexp` (`{^(?:a|b)$}iD`), `None` without names.
31fn package_names_regexp(names: &[String]) -> Option<Regex> {
32    if names.is_empty() {
33        return None;
34    }
35    let parts: Vec<String> = names
36        .iter()
37        .map(|n| crate::version::preg_quote(n).replace("\\*", ".*"))
38        .collect();
39    pcre2::bytes::RegexBuilder::new()
40        .caseless(true)
41        .build(&format!("^(?:{})\\z", parts.join("|")))
42        .ok()
43}
44
45fn matches_regex(re: &Option<Regex>, name: &str) -> bool {
46    re.as_ref()
47        .is_some_and(|r| r.is_match(name.as_bytes()).unwrap_or(false))
48}
49
50/// `name -> MultiConstraint(= v1, = v2, ...)` of the given packages (root
51/// aliases excluded), as `getMatchingSecurityAdvisories` and
52/// `getMatchingFilterLists` build it.
53pub(crate) fn constraints_by_name(
54    packages: &[usize],
55    arena: &[Package],
56) -> Vec<(String, Constraint)> {
57    let mut by_name: Vec<(String, Vec<Constraint>)> = Vec::new();
58    for &idx in packages {
59        let p = &arena[idx];
60        if p.alias_of.is_some() && p.root_package_alias {
61            continue;
62        }
63        let c = Constraint::new(Op::Eq, &p.version);
64        match by_name.iter_mut().find(|(n, _)| *n == p.name) {
65            Some((_, list)) => {
66                // `$constraintsByName[$name][$version]`: one per version.
67                if !list.iter().any(|existing| existing == &c) {
68                    list.push(c);
69                }
70            }
71            None => by_name.push((p.name.clone(), vec![c])),
72        }
73    }
74    by_name
75        .into_iter()
76        .map(|(n, list)| (n, Constraint::create(list, false)))
77        .collect()
78}
79
80fn composer_repos(repositories: &[Repository]) -> Vec<&ComposerRepository> {
81    repositories
82        .iter()
83        .filter_map(|r| match r {
84            Repository::Composer(c) => Some(c.as_ref()),
85            _ => None,
86        })
87        .collect()
88}
89
90/// `RepositorySet::getSecurityAdvisoriesForConstraints`: the advisories of
91/// all repositories, merged by name; an unreachable repository is ignored
92/// (and reported) or fatal.
93pub(crate) fn security_advisories_for_constraints(
94    repositories: &[Repository],
95    map: &[(String, Constraint)],
96    allow_partial: bool,
97    ignore_unreachable: bool,
98    unreachable: &mut Vec<String>,
99) -> Result<AdvisoriesByName, FilterError> {
100    let mut all: AdvisoriesByName = Vec::new();
101    for repo in composer_repos(repositories) {
102        // `RepositorySet::__construct`/`getSecurityAdvisoriesForConstraints`:
103        // only a TransportException falls under `ignore-unreachable`.
104        let result = repo.has_security_advisories().and_then(|has| {
105            if has {
106                repo.get_security_advisories(map, allow_partial)
107                    .map(|(_, a)| a)
108            } else {
109                Ok(Vec::new())
110            }
111        });
112        match result {
113            Ok(advisories) => {
114                for (name, list) in advisories {
115                    match all.iter_mut().find(|(n, _)| *n == name) {
116                        Some((_, existing)) => existing.extend(list),
117                        None => all.push((name, list)),
118                    }
119                }
120            }
121            Err(e) if e.is_transport() && ignore_unreachable => unreachable.push(e.0),
122            Err(e) => return Err(FilterError(e.0)),
123        }
124    }
125    Ok(all)
126}
127
128/// `Auditor::needsCompleteAdvisoryLoad`: partial advisories and an ignore
129/// rule that is not a `PKSA-` identifier.
130fn needs_complete_advisory_load(
131    advisories: &AdvisoriesByName,
132    ignore_list: &[(String, Option<String>)],
133) -> bool {
134    if advisories.is_empty() {
135        return false;
136    }
137    if advisories
138        .iter()
139        .all(|(_, list)| list.iter().all(|a| a.complete.is_some()))
140    {
141        return false;
142    }
143    ignore_list.iter().any(|(id, _)| !id.starts_with("PKSA-"))
144}
145
146/// `Auditor::processAdvisories`: what remains after the ignore rules.
147fn process_advisories(
148    all: AdvisoriesByName,
149    ignore_list: &[(String, Option<String>)],
150    ignored_severities: &[(String, Option<String>)],
151) -> AdvisoriesByName {
152    if ignore_list.is_empty() && ignored_severities.is_empty() {
153        return all;
154    }
155    let ignored = |key: &str| ignore_list.iter().any(|(k, _)| k == key);
156    let mut out: AdvisoriesByName = Vec::new();
157    for (package, list) in all {
158        for advisory in list {
159            let mut active = true;
160            if ignored(&package) || ignored(&advisory.advisory_id) {
161                active = false;
162            }
163            if let Some(c) = &advisory.complete {
164                if c.severity
165                    .as_ref()
166                    .is_some_and(|s| ignored_severities.iter().any(|(k, _)| k == s))
167                {
168                    active = false;
169                }
170                if c.cve.as_ref().is_some_and(|cve| ignored(cve)) {
171                    active = false;
172                }
173                if c.source_remote_ids.iter().any(|id| ignored(id)) {
174                    active = false;
175                }
176            }
177            if active {
178                match out.iter_mut().find(|(n, _)| *n == package) {
179                    Some((_, v)) => v.push(advisory),
180                    None => out.push((package.clone(), vec![advisory])),
181                }
182            }
183        }
184    }
185    out
186}
187
188/// `isAbandoned()` of a complete package: `abandoned` true or a replacement
189/// name.
190fn is_abandoned(p: &Package) -> bool {
191    match p.raw.get("abandoned") {
192        Some(serde_json::Value::Bool(b)) => *b,
193        Some(serde_json::Value::String(s)) => !s.is_empty(),
194        _ => false,
195    }
196}
197
198/// `SecurityAdvisoryPoolFilter::filter`: removes abandoned packages (if
199/// `abandoned.block`) and non-dev versions covered by an advisory.
200pub fn security_advisory_filter(
201    pool: Pool,
202    arena: &[Package],
203    repositories: &[Repository],
204    request: &Request,
205    policy: &PolicyConfig,
206    warnings: &mut Vec<String>,
207) -> Result<Pool, FilterError> {
208    if !policy.advisories.block {
209        return Ok(pool);
210    }
211    let ignore_list = advisory_ignore_list_for_block(&policy.advisories);
212    let ignore_unreachable = policy.ignore_unreachable.update;
213    let candidates: Vec<usize> = pool
214        .packages
215        .iter()
216        .copied()
217        .filter(|&idx| {
218            let p = &arena[idx];
219            !matches!(p.origin, crate::package::Origin::Root)
220                && !is_platform_package(&p.name)
221                && !request.is_locked_package(idx)
222        })
223        .collect();
224    let map = constraints_by_name(&candidates, arena);
225    let mut unreachable = Vec::new();
226    let mut all = security_advisories_for_constraints(
227        repositories,
228        &map,
229        true,
230        ignore_unreachable,
231        &mut unreachable,
232    )?;
233    if needs_complete_advisory_load(&all, &ignore_list) {
234        unreachable.clear();
235        all = security_advisories_for_constraints(
236            repositories,
237            &map,
238            false,
239            ignore_unreachable,
240            &mut unreachable,
241        )?;
242    }
243    if ignore_unreachable && !unreachable.is_empty() {
244        warnings.push("Security advisory data could not be fetched from some repositories (ignored per policy.ignore-unreachable); matches may be incomplete:".into());
245        for r in &unreachable {
246            warnings.push(format!("  - {r}"));
247        }
248    }
249    let advisory_map = process_advisories(
250        all,
251        &ignore_list,
252        &advisory_ignore_severity_for_block(&policy.advisories),
253    );
254    let abandoned_ignore: Vec<String> = flat_ignore_for_block(&policy.abandoned.ignore)
255        .into_iter()
256        .map(|(n, _)| n)
257        .collect();
258    let abandoned_re = package_names_regexp(&abandoned_ignore);
259    let mut kept: Vec<usize> = Vec::with_capacity(pool.packages.len());
260    let mut security_removed: BTreeMap<String, Vec<(String, Vec<String>)>> = BTreeMap::new();
261    let mut abandoned_removed: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
262    for &idx in &pool.packages {
263        let p = &arena[idx];
264        if policy.abandoned.block && is_abandoned(p) && !matches_regex(&abandoned_re, &p.name) {
265            for name in p.names(false) {
266                abandoned_removed
267                    .entry(name)
268                    .or_default()
269                    .insert(p.version.clone(), p.pretty_version.clone());
270            }
271            continue;
272        }
273        let matching = matching_advisories(p, &advisory_map);
274        if !matching.is_empty() {
275            let ids: Vec<String> = matching.iter().map(|a| a.advisory_id.clone()).collect();
276            for name in p.names(false) {
277                let list = security_removed.entry(name).or_default();
278                match list.iter_mut().find(|(v, _)| *v == p.version) {
279                    Some(slot) => slot.1 = ids.clone(),
280                    None => list.push((p.version.clone(), ids.clone())),
281                }
282            }
283            continue;
284        }
285        kept.push(idx);
286    }
287    if kept.len() == pool.packages.len() {
288        return Ok(pool);
289    }
290    let mut out = pool.with_packages(kept, arena);
291    out.security_removed = security_removed;
292    out.abandoned_removed = abandoned_removed;
293    Ok(out)
294}
295
296/// `getMatchingAdvisories`: never for a dev version; on each of the
297/// `getNames(false)` (name + `replace`).
298fn matching_advisories<'a>(p: &Package, advisory_map: &'a AdvisoriesByName) -> Vec<&'a Advisory> {
299    if p.is_dev() {
300        return Vec::new();
301    }
302    let constraint = Constraint::new(Op::Eq, &p.version);
303    let mut out = Vec::new();
304    for name in p.names(false) {
305        let Some((_, list)) = advisory_map.iter().find(|(n, _)| *n == name) else {
306            continue;
307        };
308        for a in list {
309            if a.affected_versions.matches(&constraint) {
310                out.push(a);
311            }
312        }
313    }
314    out
315}
316
317/// `FilterListPoolFilter::filter` in `update` or `install` scope: the
318/// versions flagged by an active list; locked versions (or versions
319/// identical to a lock version) are judged against the `install`-scoped
320/// lists.
321pub fn filter_list_filter(
322    pool: Pool,
323    arena: &[Package],
324    repositories: &[Repository],
325    request: &Request,
326    policy: &PolicyConfig,
327    block_scope: &str,
328    warnings: &mut Vec<String>,
329) -> Result<Pool, FilterError> {
330    // A custom list with no source and no repository advertising it is
331    // inert in Composer; otherwise it requires a provider that is not
332    // ported.
333    if !policy.custom_lists.is_empty() {
334        for repo in composer_repos(repositories) {
335            if let Ok(lists) = repo.get_filter_lists() {
336                if let Some(l) = lists.iter().find(|l| policy.custom_lists.contains(l)) {
337                    return Err(FilterError(format!(
338                        "custom policy list \"{l}\" is not supported by vivacity yet"
339                    )));
340                }
341            }
342        }
343    }
344    let check_locked_against_install = block_scope == "update";
345    let configured: Vec<String> = if policy.malware_blocks(block_scope) {
346        vec!["malware".to_owned()]
347    } else {
348        Vec::new()
349    };
350    let install_lists: Vec<String> =
351        if check_locked_against_install && policy.malware_blocks("install") {
352            vec!["malware".to_owned()]
353        } else {
354            Vec::new()
355        };
356    let mut union: Vec<String> = configured.clone();
357    for l in &install_lists {
358        if !union.contains(l) {
359            union.push(l.clone());
360        }
361    }
362    if union.is_empty() {
363        return Ok(pool);
364    }
365    let mut ignore_unreachable = policy.ignore_unreachable.for_block_scope(block_scope);
366    if check_locked_against_install {
367        ignore_unreachable = ignore_unreachable && policy.ignore_unreachable.install;
368    }
369    let filterable: Vec<usize> = pool
370        .packages
371        .iter()
372        .copied()
373        .filter(|&idx| {
374            let p = &arena[idx];
375            !matches!(p.origin, crate::package::Origin::Root) && !is_platform_package(&p.name)
376        })
377        .collect();
378    let map = constraints_by_name(&filterable, arena);
379    // `FilterListProviderSet::getMatchingFilterLists`.
380    let mut by_list: Vec<(String, Vec<FilterEntry>)> = Vec::new();
381    let mut unreachable = Vec::new();
382    for repo in composer_repos(repositories) {
383        // `FilterListProviderSet`: `hasFilter()` (packages.json) and
384        // `getFilter()` alike only surface their TransportException under
385        // `ignore-unreachable`.
386        let provider_lists = match repo.get_filter_lists() {
387            Ok(l) => l,
388            Err(e) if e.is_transport() && ignore_unreachable => {
389                unreachable.push(e.0);
390                continue;
391            }
392            Err(e) => return Err(FilterError(e.0)),
393        };
394        let relevant: Vec<String> = union
395            .iter()
396            .filter(|l| provider_lists.contains(l))
397            .cloned()
398            .collect();
399        if relevant.is_empty() {
400            continue;
401        }
402        match repo.get_filter(&map, &relevant) {
403            Ok(filter) => {
404                for (list, entries) in filter {
405                    if !union.contains(&list) || !provider_lists.contains(&list) {
406                        continue;
407                    }
408                    for entry in entries {
409                        let Some((_, wanted)) = map.iter().find(|(n, _)| *n == entry.package_name)
410                        else {
411                            continue;
412                        };
413                        if !entry.constraint.matches(wanted) {
414                            continue;
415                        }
416                        match by_list.iter_mut().find(|(l, _)| *l == list) {
417                            Some((_, v)) => v.push(entry),
418                            None => by_list.push((list.clone(), vec![entry])),
419                        }
420                    }
421                }
422            }
423            Err(e) if e.is_transport() && ignore_unreachable => unreachable.push(e.0),
424            Err(e) => return Err(FilterError(e.0)),
425        }
426    }
427    by_list.sort_by(|(a, _), (b, _)| a.cmp(b));
428    if std::env::var_os("VIVACITY_TRACE").is_some() {
429        eprintln!(
430            "trace: filter lists       {union:?} → {} entries ({} names queried)",
431            by_list.iter().map(|(_, e)| e.len()).sum::<usize>(),
432            map.len()
433        );
434    }
435    if !unreachable.is_empty() {
436        warnings.push("Filter list data could not be fetched from some sources (ignored per policy.ignore-unreachable); matches may be incomplete:".into());
437        for r in &unreachable {
438            warnings.push(format!("  - {r}"));
439        }
440    }
441    // `$filterListMap[$packageName][$listName][] = $entry`.
442    let mut filter_map: BTreeMap<String, Vec<(String, Vec<FilterEntry>)>> = BTreeMap::new();
443    for (list, entries) in &by_list {
444        for e in entries {
445            let lists = filter_map.entry(e.package_name.clone()).or_default();
446            match lists.iter_mut().find(|(l, _)| l == list) {
447                Some((_, v)) => v.push(e.clone()),
448                None => lists.push((list.clone(), vec![e.clone()])),
449            }
450        }
451    }
452    if filter_map.is_empty() {
453        return Ok(pool);
454    }
455    let locked_versions: BTreeMap<String, Vec<String>> = if check_locked_against_install {
456        let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();
457        for idx in request.locked_repository.iter().flatten() {
458            let p = &arena[*idx];
459            m.entry(p.name.clone()).or_default().push(p.version.clone());
460        }
461        m
462    } else {
463        BTreeMap::new()
464    };
465    let mut kept: Vec<usize> = Vec::with_capacity(pool.packages.len());
466    let mut removed: crate::pool::FilterListRemoved = pool.filter_list_removed.clone();
467    for &idx in &pool.packages {
468        let p = &arena[idx];
469        if matches!(p.origin, crate::package::Origin::Root) || is_platform_package(&p.name) {
470            kept.push(idx);
471            continue;
472        }
473        let locked_equivalent = check_locked_against_install
474            && (request.is_locked_package(idx)
475                || locked_versions
476                    .get(&p.name)
477                    .is_some_and(|v| v.contains(&p.version)));
478        let (lists, scope) = if locked_equivalent {
479            (&install_lists, "install")
480        } else {
481            (&configured, block_scope)
482        };
483        let matching = matching_entries(p, &filter_map, lists, policy, scope);
484        if matching.is_empty() {
485            kept.push(idx);
486            continue;
487        }
488        for name in p.names(false) {
489            let versions = removed.entry(name).or_default();
490            match versions.iter_mut().find(|(v, _)| *v == p.version) {
491                Some((_, e)) => *e = matching.clone(),
492                None => versions.push((p.version.clone(), matching.clone())),
493            }
494        }
495    }
496    let mut out = pool.with_packages(kept, arena);
497    out.filter_list_removed = removed;
498    Ok(out)
499}
500
501/// `FilterListAuditor::matchingEntries` for the `block` operation: the
502/// entries of the active lists covering the version, unless ignored.
503fn matching_entries(
504    p: &Package,
505    filter_map: &BTreeMap<String, Vec<(String, Vec<FilterEntry>)>>,
506    active_lists: &[String],
507    policy: &PolicyConfig,
508    _scope: &str,
509) -> Vec<FilterEntry> {
510    if filter_map.is_empty() || active_lists.is_empty() {
511        return Vec::new();
512    }
513    let malware_active = active_lists.iter().any(|l| l == "malware");
514    let ignore_source = &policy.malware.ignore_source;
515    let ignore_map: &IgnoreMap = &policy.malware.ignore;
516    let ignored_names: Vec<String> = flat_ignore_for_block(ignore_map)
517        .into_iter()
518        .map(|(n, _)| n)
519        .collect();
520    let ignored_re = package_names_regexp(&ignored_names);
521    let constraint = Constraint::new(Op::Eq, &p.version);
522    let mut out = Vec::new();
523    for name in p.names(false) {
524        let Some(lists) = filter_map.get(&name) else {
525            continue;
526        };
527        for (list, entries) in lists {
528            if !active_lists.contains(list) {
529                continue;
530            }
531            // `applyMalwareIgnoreSource`: the entries of an ignored source.
532            let entries: Vec<&FilterEntry> = entries
533                .iter()
534                .filter(|e| {
535                    !(list == "malware"
536                        && malware_active
537                        && e.source.as_ref().is_some_and(|s| ignore_source.contains(s)))
538                })
539                .collect();
540            if matches_regex(&ignored_re, &name) && list == "malware" {
541                // `isPackageIgnored`: a rule whose pattern and constraint
542                // cover the version dismisses the list.
543                let ignored = ignore_map.iter().any(|(_, rules)| {
544                    rules.iter().any(|r| {
545                        r.on_block
546                            && matches_regex(
547                                &package_names_regexp(std::slice::from_ref(&r.package_name)),
548                                &name,
549                            )
550                            && r.constraint.matches(&constraint)
551                    })
552                });
553                if ignored {
554                    continue;
555                }
556            }
557            for e in entries {
558                if e.constraint.matches(&constraint) {
559                    out.push(e.clone());
560                }
561            }
562        }
563    }
564    out
565}
566
567/// Composer's text for a removed locked package:
568/// `getFilterListEntryForPackageVersion` + the
569/// `RULE_LOCKED_FILTER_LIST_REMOVED` problem of `Problem::getPrettyString`.
570pub fn locked_removed_problem_text(pool: &Pool, p: &Package) -> String {
571    let mut lists: Vec<(String, Vec<String>)> = Vec::new();
572    if let Some(versions) = pool.filter_list_removed.get(&p.name) {
573        for (v, entries) in versions {
574            if *v != p.version {
575                continue;
576            }
577            for e in entries {
578                let source = e
579                    .source
580                    .as_ref()
581                    .filter(|s| !s.is_empty())
582                    .map(|s| format!(" reported by {s}"))
583                    .unwrap_or_default();
584                let url = e
585                    .url
586                    .as_ref()
587                    .filter(|s| !s.is_empty())
588                    .map(|s| format!(" (see {s})"))
589                    .unwrap_or_default();
590                let reason = e
591                    .reason
592                    .as_ref()
593                    .filter(|s| !s.is_empty())
594                    .map(|s| format!(" reason: {s}"))
595                    .unwrap_or_default();
596                let text = format!("{source}{url}{reason}");
597                match lists.iter_mut().find(|(l, _)| *l == e.list_name) {
598                    Some((_, v)) => v.push(text),
599                    None => lists.push((e.list_name.clone(), vec![text])),
600                }
601            }
602        }
603    }
604    let filters: Vec<String> = lists
605        .iter()
606        .map(|(l, entries)| {
607            let action = if l == "malware" {
608                "flagged as "
609            } else {
610                "filtered by "
611            };
612            format!("{action}{l}{}", entries.join(", "))
613        })
614        .collect();
615    let ignore_paths: Vec<String> = lists
616        .iter()
617        .map(|(l, _)| format!("\"policy.{l}.ignore\""))
618        .collect();
619    let off_paths: Vec<String> = lists
620        .iter()
621        .map(|(l, _)| format!("\"policy.{l}.block\""))
622        .collect();
623    format!(
624        "- Package {} {} (in the lock file) was not loaded, because it was {}. To ignore filters for this package, add the package to the {} config. To turn the feature off entirely, you can set {} to false.",
625        p.name,
626        p.pretty_version,
627        filters.join(", "),
628        ignore_paths.join(" and "),
629        off_paths.join(" and ")
630    )
631}