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