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