Skip to main content

vivacity_resolver/
session.rs

1//! Setup of an `update`: what `Factory::createComposer` then
2//! `Installer::doUpdate` do before `createPool`: root, platform,
3//! repositories (global config + composer.json, same merge rules as
4//! `Config::merge`), lock repository, `Request`. Rust-side replica of
5//! tools/oracle-pool.php.
6
7use crate::constraint::{Constraint, Op};
8use crate::lockfile::{dump_package, lock_data, lock_packages, LockInput};
9use crate::optimizer::PoolOptimizer;
10use crate::package::{Origin, Package};
11use crate::platform::{platform_packages, probe};
12use crate::platform_filter::PlatformRequirementFilter;
13use crate::policy::DefaultPolicy;
14use crate::pool::{OrderedMap, Pool, PoolError, Repository, RepositorySet, Request};
15use crate::repository::{
16    locked_repository, ComposerRepository, FileTransport, HttpTransport, HttpTransports,
17};
18use crate::root::RootPackage;
19use crate::solver::{SolveError, Solver};
20use crate::transaction::LockTransaction;
21use crate::version::{parse_stability, regex, stability_rank};
22use pcre2::bytes::Regex;
23use serde_json::{Map, Value};
24use std::collections::BTreeMap;
25use std::path::Path;
26use std::sync::OnceLock;
27
28#[derive(Debug, thiserror::Error)]
29#[error("{message}")]
30pub struct SessionError {
31    pub message: String,
32    pub kind: SessionErrorKind,
33}
34
35/// What Composer does with the error: an unsolvable set
36/// (`SolverProblemsException`) means exit code 2 from `Installer::run`,
37/// everything else is an exception that propagates.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum SessionErrorKind {
40    Other,
41    Unsolvable,
42}
43
44impl SessionError {
45    pub fn new(message: impl Into<String>) -> Self {
46        Self {
47            message: message.into(),
48            kind: SessionErrorKind::Other,
49        }
50    }
51}
52
53impl From<PoolError> for SessionError {
54    fn from(e: PoolError) -> SessionError {
55        SessionError::new(e.0)
56    }
57}
58
59/// `Config::$repositories` after merging: (name or index, definition).
60#[derive(Debug, Clone, PartialEq)]
61pub struct RepoConfig {
62    pub key: RepoKey,
63    pub definition: Value,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum RepoKey {
68    Named(String),
69    Indexed(u64),
70}
71
72/// `Config::merge` for the `repositories` key, applied in order (defaults,
73/// global config, composer.json).
74pub fn merge_repositories(current: &mut Vec<RepoConfig>, new: &Value) {
75    static PACKAGIST: OnceLock<Regex> = OnceLock::new();
76    let entries: Vec<(RepoKey, Value)> = match new {
77        Value::Array(list) => list
78            .iter()
79            .enumerate()
80            .map(|(i, v)| (RepoKey::Indexed(i as u64), v.clone()))
81            .collect(),
82        Value::Object(map) => map
83            .iter()
84            .map(|(k, v)| {
85                let key = match k.parse::<u64>() {
86                    Ok(i) if i.to_string() == *k => RepoKey::Indexed(i),
87                    _ => RepoKey::Named(k.clone()),
88                };
89                (key, v.clone())
90            })
91            .collect(),
92        _ => return,
93    };
94    if entries.is_empty() {
95        return;
96    }
97    current.reverse();
98    // `disableRepoByName((string) $name)`: a numeric string falls back to
99    // the integer key of the PHP array.
100    let disable = |current: &mut Vec<RepoConfig>, name: &str| {
101        let key = match name.parse::<u64>() {
102            Ok(i) if i.to_string() == name => RepoKey::Indexed(i),
103            _ => RepoKey::Named(name.to_owned()),
104        };
105        if current.iter().any(|r| r.key == key) {
106            current.retain(|r| r.key != key);
107        } else if name == "packagist" {
108            current.retain(|r| r.key != RepoKey::Named("packagist.org".into()));
109        }
110    };
111    for (key, repository) in entries.into_iter().rev() {
112        if repository == Value::Bool(false) {
113            let name = match &key {
114                RepoKey::Named(n) => n.clone(),
115                RepoKey::Indexed(i) => i.to_string(),
116            };
117            disable(current, &name);
118            continue;
119        }
120        if let Some(obj) = repository.as_object() {
121            if obj.len() == 1 && obj.values().next() == Some(&Value::Bool(false)) {
122                disable(current, obj.keys().next().map(String::as_str).unwrap_or(""));
123                continue;
124            }
125        }
126        if repository.get("type").and_then(Value::as_str) == Some("composer") {
127            if let Some(url) = repository.get("url").and_then(Value::as_str) {
128                let re = regex(
129                    &PACKAGIST,
130                    r"^https?://(?:[a-z0-9-.]+\.)?packagist.org(/|$)",
131                    false,
132                );
133                if re.is_match(url.as_bytes()).unwrap_or(false) {
134                    disable(current, "packagist.org");
135                }
136            }
137        }
138        match key {
139            RepoKey::Indexed(i) => {
140                if current.iter().any(|r| r.key == RepoKey::Indexed(i)) {
141                    let next = current
142                        .iter()
143                        .filter_map(|r| match r.key {
144                            RepoKey::Indexed(j) => Some(j + 1),
145                            _ => None,
146                        })
147                        .max()
148                        .unwrap_or(0);
149                    current.push(RepoConfig {
150                        key: RepoKey::Indexed(next),
151                        definition: repository,
152                    });
153                } else {
154                    current.push(RepoConfig {
155                        key: RepoKey::Indexed(i),
156                        definition: repository,
157                    });
158                }
159            }
160            RepoKey::Named(name) => {
161                let name = if name == "packagist" {
162                    "packagist.org".to_owned()
163                } else {
164                    name
165                };
166                let key = RepoKey::Named(name);
167                if let Some(slot) = current.iter_mut().find(|r| r.key == key) {
168                    slot.definition = repository;
169                } else {
170                    current.push(RepoConfig {
171                        key,
172                        definition: repository,
173                    });
174                }
175            }
176        }
177    }
178    current.reverse();
179}
180
181/// Merged configuration relevant to the resolver.
182#[derive(Debug, Clone, Default)]
183pub struct MergedConfig {
184    pub repositories: Vec<RepoConfig>,
185    /// `config.platform` (the last definition replaces the previous one).
186    pub platform: Map<String, Value>,
187    /// `config.policy` and `config.audit` merged (`Config::merge`).
188    pub policy: crate::policy_config::RawPolicyConfig,
189}
190
191impl MergedConfig {
192    /// Defaults + `COMPOSER_HOME/config.json` + composer.json, like
193    /// `Factory::createConfig` then `Config::merge($localConfig)`.
194    pub fn load(
195        manifest: &Value,
196        composer_home: Option<&Path>,
197    ) -> Result<MergedConfig, SessionError> {
198        let mut cfg = MergedConfig {
199            repositories: vec![RepoConfig {
200                key: RepoKey::Named("packagist.org".into()),
201                definition: serde_json::json!({"type": "composer", "url": "https://repo.packagist.org"}),
202            }],
203            platform: Map::new(),
204            policy: crate::policy_config::RawPolicyConfig::default(),
205        };
206        if let Some(home) = composer_home {
207            let global = home.join("config.json");
208            if global.is_file() {
209                let text = std::fs::read_to_string(&global)
210                    .map_err(|e| SessionError::new(format!("{}: {e}", global.display())))?;
211                let v: Value = serde_json::from_str(&text)
212                    .map_err(|e| SessionError::new(format!("{}: {e}", global.display())))?;
213                cfg.merge(&v);
214            }
215        }
216        cfg.merge(manifest);
217        Ok(cfg)
218    }
219
220    fn merge(&mut self, config: &Value) {
221        // `$this->config['platform'] = $val`: any value replaces the
222        // previous one (`[]` or `null` clear it).
223        if let Some(platform) = config.get("config").and_then(|c| c.get("platform")) {
224            self.platform = platform.as_object().cloned().unwrap_or_default();
225        }
226        if let Some(repos) = config.get("repositories") {
227            merge_repositories(&mut self.repositories, repos);
228        }
229        if let Some(cfg) = config.get("config").and_then(Value::as_object) {
230            self.policy.merge(cfg);
231        }
232    }
233}
234
235/// Result of a `solve`: the transaction and what is needed to compare it
236/// with Composer.
237pub struct SolveReport {
238    pub transaction: LockTransaction,
239    /// Decided literals, in order.
240    pub decisions: Vec<i64>,
241    /// `getRuleSetSize()`.
242    pub rules: usize,
243    /// Learned rules (conflicts encountered).
244    pub learned: usize,
245}
246
247/// Options of an `update` (`Installer::setUpdateAllowList`,
248/// `setUpdateAllowTransitiveDependencies`).
249#[derive(Debug, Clone, Default)]
250pub struct UpdateOptions {
251    /// `composer update a/b c/*`: patterns, lowercased and deduplicated.
252    pub allow_list: Vec<String>,
253    pub transitive: Option<crate::pool::UpdateMode>,
254    /// `--no-blocking` / `--no-security-blocking`.
255    pub no_blocking: bool,
256}
257
258impl UpdateOptions {
259    /// `Installer::setUpdateAllowList`: `strtolower` + `array_unique`.
260    pub fn partial(packages: &[String], transitive: crate::pool::UpdateMode) -> UpdateOptions {
261        let mut allow_list: Vec<String> = Vec::new();
262        for p in packages {
263            let l = p.to_lowercase();
264            if !allow_list.contains(&l) {
265                allow_list.push(l);
266            }
267        }
268        UpdateOptions {
269            allow_list,
270            transitive: Some(transitive),
271            no_blocking: false,
272        }
273    }
274}
275
276/// Everything `Installer::doUpdate` has at hand right before `createPool`.
277pub struct UpdateSession {
278    pub arena: Vec<Package>,
279    pub root: RootPackage,
280    /// Arena indices of the fixed root (requires emptied) and of its alias.
281    pub fixed_root: usize,
282    pub fixed_root_alias: Option<usize>,
283    pub platform: Vec<usize>,
284    pub locked: Option<Vec<usize>>,
285    pub set: RepositorySet,
286    pub request: Request,
287    pub config: MergedConfig,
288    pub dev_mode: bool,
289    /// `--prefer-stable` / `--prefer-lowest` from the command line.
290    pub prefer_stable: bool,
291    pub prefer_lowest: bool,
292    /// `PHP_MAJOR.MINOR.RELEASE` of the probed PHP (`ext-*` rule of the
293    /// `VersionSelector`).
294    pub php_version: String,
295    /// The pool blocking policies (`createPolicyConfig`).
296    pub policy_config: crate::policy_config::PolicyConfig,
297}
298
299impl UpdateSession {
300    pub fn prepare(
301        project_dir: &Path,
302        composer_home: Option<&Path>,
303        dev_mode: bool,
304    ) -> Result<UpdateSession, SessionError> {
305        Self::prepare_with(project_dir, composer_home, dev_mode, None)
306    }
307
308    /// Like `prepare`, with a network transport for `https://` repositories.
309    pub fn prepare_with(
310        project_dir: &Path,
311        composer_home: Option<&Path>,
312        dev_mode: bool,
313        http: Option<HttpTransports>,
314    ) -> Result<UpdateSession, SessionError> {
315        Self::prepare_full(project_dir, composer_home, dev_mode, http, None)
316    }
317
318    /// Like `prepare_with`, with Composer's `cache-repo-dir` directory for
319    /// the metadata cache (read and written in Composer's format).
320    pub fn prepare_full(
321        project_dir: &Path,
322        composer_home: Option<&Path>,
323        dev_mode: bool,
324        http: Option<HttpTransports>,
325        cache_repo_dir: Option<&Path>,
326    ) -> Result<UpdateSession, SessionError> {
327        Self::prepare_update(
328            project_dir,
329            composer_home,
330            dev_mode,
331            http,
332            cache_repo_dir,
333            &UpdateOptions::default(),
334        )
335    }
336
337    /// Like `prepare_full`, with the allow list of a partial update
338    /// (`composer update a/b`).
339    pub fn prepare_update(
340        project_dir: &Path,
341        composer_home: Option<&Path>,
342        dev_mode: bool,
343        http: Option<HttpTransports>,
344        cache_repo_dir: Option<&Path>,
345        options: &UpdateOptions,
346    ) -> Result<UpdateSession, SessionError> {
347        let partial_update = !options.allow_list.is_empty();
348        let manifest_path = project_dir.join("composer.json");
349        let manifest_text = std::fs::read_to_string(&manifest_path)
350            .map_err(|e| SessionError::new(format!("{}: {e}", manifest_path.display())))?;
351        let manifest: Value = serde_json::from_str(&manifest_text)
352            .map_err(|e| SessionError::new(format!("{}: {e}", manifest_path.display())))?;
353        let config = MergedConfig::load(&manifest, composer_home)?;
354        let mut policy_config = crate::policy_config::PolicyConfig::from_raw(&config.policy)
355            .map_err(|e| SessionError::new(e.0))?;
356        policy_config
357            .apply_no_blocking(options.no_blocking)
358            .map_err(|e| SessionError::new(e.0))?;
359        let root = RootPackage::load(&manifest, project_dir).map_err(|e| SessionError::new(e.0))?;
360        let probed = probe().map_err(|e| SessionError::new(e.0))?;
361        // `PHP_MAJOR_VERSION.PHP_MINOR_VERSION.PHP_RELEASE_VERSION` of the
362        // actual PHP (no `config.platform.php` here): the first three
363        // numbers of PHP_VERSION.
364        let php_version = probed
365            .iter()
366            .find(|p| p.get("name").and_then(Value::as_str) == Some("php"))
367            .and_then(|p| p.get("version").and_then(Value::as_str))
368            .map(|v| {
369                v.split(['.', '-', '+'])
370                    .take(3)
371                    .map(|part| part.trim_end_matches(|c: char| !c.is_ascii_digit()))
372                    .collect::<Vec<_>>()
373                    .join(".")
374            })
375            .unwrap_or_default();
376        let platform_pkgs =
377            platform_packages(&probed, &config.platform).map_err(|e| SessionError::new(e.0))?;
378
379        let mut arena: Vec<Package> = Vec::new();
380
381        // `$fixedRootPackage = clone $package; setRequires([]); setDevRequires([])`.
382        let mut fixed = root.package.clone();
383        fixed.requires = Default::default();
384        fixed.dev_requires = Default::default();
385        arena.push(fixed);
386        let fixed_root = 0;
387        let fixed_root_alias = root.branch_alias.as_ref().map(|(normalized, pretty)| {
388            let a = arena[fixed_root].alias(fixed_root, normalized, pretty);
389            arena.push(a);
390            arena.len() - 1
391        });
392        let root_members: Vec<usize> = fixed_root_alias
393            .into_iter()
394            .chain(std::iter::once(fixed_root))
395            .collect();
396
397        let mut platform: Vec<usize> = Vec::new();
398        for p in platform_pkgs {
399            arena.push(p);
400            platform.push(arena.len() - 1);
401        }
402
403        // `Locker::isLocked()` = file present and `isset($data['packages'])`;
404        // an unreadable lock is ignored for a full update (`doUpdate`
405        // swallows the ParsingException), fatal for a partial one.
406        let lock_path = project_dir.join("composer.lock");
407        let lock: Option<Value> = if lock_path.is_file() {
408            let text = std::fs::read_to_string(&lock_path)
409                .map_err(|e| SessionError::new(format!("{}: {e}", lock_path.display())))?;
410            match serde_json::from_str::<Value>(&text) {
411                Ok(v) if v.get("packages").is_some_and(|p| !p.is_null()) => Some(v),
412                Ok(_) => None,
413                Err(e) => {
414                    if partial_update {
415                        return Err(SessionError::new(format!(
416                            "\"{}\" does not contain valid JSON\n{e}",
417                            lock_path.display()
418                        )));
419                    }
420                    None
421                }
422            }
423        } else {
424            None
425        };
426        let locked = match &lock {
427            Some(v) => Some(locked_repository(v, &mut arena).map_err(|e| SessionError::new(e.0))?),
428            None => None,
429        };
430
431        // `$stabilityFlags[$package->getName()] = STABILITIES[parseStability($package->getVersion())]`:
432        // the version seen is that of the root alias if there is one.
433        let mut stability_flags = root.stability_flags.clone();
434        let root_version = match fixed_root_alias {
435            Some(a) => arena[a].version.clone(),
436            None => arena[fixed_root].version.clone(),
437        };
438        stability_flags.insert(
439            root.package.name.clone(),
440            stability_rank(parse_stability(&root_version)),
441        );
442
443        // `createRepositorySet(forUpdate)` and `requirePackagesForUpdate(..., true)`
444        // always take require + require-dev, `--no-dev` or not; and with a
445        // root alias, `$this->package` is the RootAliasPackage, whose
446        // `self.version` links target the alias version.
447        let mut root_requires: OrderedMap<Constraint> = OrderedMap::default();
448        let requires = match &root.branch_alias {
449            Some((normalized, pretty)) => {
450                let aliased = root.package.alias(0, normalized, pretty);
451                let mut all = aliased.requires.clone();
452                for l in aliased.dev_requires.iter() {
453                    all.insert(l.clone());
454                }
455                all
456            }
457            None => root.all_requires(),
458        };
459        for link in requires.iter() {
460            root_requires.insert(&link.target, link.constraint.clone());
461        }
462
463        let mut set = RepositorySet::new(
464            &root.minimum_stability,
465            stability_flags,
466            &root.aliases,
467            root.references.clone(),
468            root_requires,
469            Default::default(),
470        );
471        set.add_repository(Repository::Root(root_members));
472        set.add_repository(Repository::Platform(platform.clone()));
473        for repo in &config.repositories {
474            set.add_repository(open_repository(repo, http.as_ref(), cache_repo_dir)?);
475        }
476        if let Some(ids) = &locked {
477            set.add_repository(Repository::Locked(ids.clone()));
478        }
479
480        // `Installer::run`: a partial update requires a lock.
481        if partial_update && locked.is_none() {
482            return Err(SessionError::new(
483                "Cannot update only a partial set of packages without a lock file present. Run `composer update` to generate a lock file.",
484            ));
485        }
486        let mut request = Request::new(locked.clone());
487        if partial_update {
488            request.set_update_allow_list(
489                options.allow_list.clone(),
490                options
491                    .transitive
492                    .unwrap_or(crate::pool::UpdateMode::OnlyListed),
493            );
494        }
495        if let Some(a) = fixed_root_alias {
496            request.fix_package(a);
497        }
498        request.fix_package(fixed_root);
499        for &p in &platform {
500            let provided = arena[fixed_root]
501                .provides
502                .get(&arena[p].name)
503                .map(|l| l.constraint.clone());
504            let provided_here = provided
505                .is_some_and(|c| c.matches(&Constraint::new(Op::Eq, arena[p].version.clone())));
506            if !provided_here {
507                request.fix_package(p);
508            }
509        }
510        for link in requires.iter() {
511            request.require_name(&link.target, Some(link.constraint.clone()))?;
512        }
513
514        Ok(UpdateSession {
515            arena,
516            root,
517            fixed_root,
518            fixed_root_alias,
519            platform,
520            locked,
521            set,
522            request,
523            config,
524            dev_mode,
525            prefer_stable: false,
526            prefer_lowest: false,
527            php_version,
528            policy_config,
529        })
530    }
531
532    pub fn create_pool(&mut self) -> Result<Pool, SessionError> {
533        Ok(self.set.create_pool(&mut self.request, &mut self.arena)?)
534    }
535
536    /// `Installer::createPolicy(true, ...)` without `--minimal-changes`.
537    pub fn policy(&self) -> DefaultPolicy {
538        DefaultPolicy::new(
539            self.prefer_stable || self.root.prefer_stable,
540            self.prefer_lowest,
541            None,
542        )
543    }
544
545    /// `Installer::extractDevPackages`: second solve without the
546    /// require-dev, on only the packages kept by the first, to classify
547    /// `packages` / `packages-dev`.
548    pub fn extract_dev_packages(
549        &mut self,
550        transaction: &mut LockTransaction,
551        policy: &mut DefaultPolicy,
552        filter: &PlatformRequirementFilter,
553    ) -> Result<(), SessionError> {
554        if self.root.package.dev_requires.is_empty() {
555            return Ok(());
556        }
557        // `$resultRepo`: each package reloaded from its dump (`load`, one at
558        // a time), branch aliases recreated -> [alias, base].
559        let dumps: Vec<Value> = transaction
560            .new_lock_packages(&self.arena, false)
561            .iter()
562            .map(|&idx| Value::Object(dump_package(&self.arena[idx])))
563            .collect();
564        let result_ids =
565            crate::loader::load_packages(&dumps, Origin::Result, &mut self.arena, false)
566                .map_err(|e| SessionError::new(e.0))?;
567        // createPoolWithAllPackages: root, platform, result, with the root
568        // aliases applied along the way.
569        let mut members: Vec<usize> = Vec::new();
570        members.extend(self.fixed_root_alias);
571        members.push(self.fixed_root);
572        members.extend(self.platform.iter().copied());
573        members.extend(result_ids);
574        let mut pool_packages: Vec<usize> = Vec::new();
575        for idx in members {
576            pool_packages.push(idx);
577            let (name, version) = (
578                self.arena[idx].name.clone(),
579                self.arena[idx].version.clone(),
580            );
581            if let Some((alias, alias_normalized)) = self
582                .set
583                .root_aliases
584                .get(&name)
585                .and_then(|m| m.get(&version))
586            {
587                let mut base = idx;
588                while let Some(b) = self.arena[base].alias_of {
589                    base = b;
590                }
591                let mut a = self.arena[base].alias(base, alias_normalized, alias);
592                a.root_package_alias = true;
593                a.origin = Origin::Detached;
594                self.arena.push(a);
595                pool_packages.push(self.arena.len() - 1);
596            }
597        }
598        let pool = Pool::new(pool_packages, Vec::new(), &self.arena);
599        // createRequest (without lock) + requirePackagesForUpdate(..., false).
600        let mut request = Request::new(None);
601        if let Some(a) = self.fixed_root_alias {
602            request.fix_package(a);
603        }
604        request.fix_package(self.fixed_root);
605        for &p in &self.platform {
606            let provided = self.arena[self.fixed_root]
607                .provides
608                .get(&self.arena[p].name)
609                .map(|l| l.constraint.clone());
610            let provided_here = provided.is_some_and(|c| {
611                c.matches(&Constraint::new(Op::Eq, self.arena[p].version.clone()))
612            });
613            if !provided_here {
614                request.fix_package(p);
615            }
616        }
617        let requires = match &self.root.branch_alias {
618            Some((normalized, pretty)) => self.root.package.alias(0, normalized, pretty).requires,
619            None => self.root.package.requires.clone(),
620        };
621        for link in requires.iter() {
622            request.require_name(&link.target, Some(link.constraint.clone()))?;
623        }
624        let mut solver = Solver::new(&pool, &self.arena);
625        let non_dev = solver.solve(&request, policy, filter).map_err(|e| match e {
626            SolveError::Problems(_) => SessionError {
627                message: "Unable to find a compatible set of packages based on your non-dev requirements alone.\nYour requirements can be resolved successfully when require-dev packages are present.\nYou may need to move packages from require-dev or some of their dependencies to require.".into(),
628                kind: SessionErrorKind::Unsolvable,
629            },
630            SolveError::Bug(b) => SessionError::new(b),
631        })?;
632        transaction.set_non_dev_packages(&self.arena, &non_dev);
633        Ok(())
634    }
635
636    /// `extractPlatformRequirements($links)`.
637    fn platform_requirements(links: &crate::package::Links) -> Map<String, Value> {
638        let mut out = Map::new();
639        for l in links.iter() {
640            if crate::platform::is_platform_package(&l.target) {
641                out.insert(l.target.clone(), Value::String(l.pretty_constraint.clone()));
642            }
643        }
644        out
645    }
646
647    /// `Locker::setLockData(...)`: the lock JSON to write.
648    pub fn lock_json(
649        &self,
650        transaction: &LockTransaction,
651        manifest_text: &str,
652    ) -> Result<Value, SessionError> {
653        let content_hash = vivacity_core::content_hash::content_hash(manifest_text)
654            .map_err(|e| SessionError::new(e.to_string()))?;
655        let packages = lock_packages(
656            &self.arena,
657            &transaction.new_lock_packages(&self.arena, false),
658        )
659        .map_err(SessionError::new)?;
660        let packages_dev = lock_packages(
661            &self.arena,
662            &transaction.new_lock_packages(&self.arena, true),
663        )
664        .map_err(SessionError::new)?;
665        let (requires, dev_requires) = match &self.root.branch_alias {
666            Some((normalized, pretty)) => {
667                let a = self.root.package.alias(0, normalized, pretty);
668                (a.requires, a.dev_requires)
669            }
670            None => (
671                self.root.package.requires.clone(),
672                self.root.package.dev_requires.clone(),
673            ),
674        };
675        Ok(lock_data(LockInput {
676            content_hash: &content_hash,
677            packages,
678            packages_dev: Some(packages_dev),
679            platform: Self::platform_requirements(&requires),
680            platform_dev: Self::platform_requirements(&dev_requires),
681            aliases: &transaction.aliases(&self.arena, &self.root.aliases),
682            minimum_stability: &self.root.minimum_stability,
683            stability_flags: &self.root.stability_flags,
684            prefer_stable: self.prefer_stable || self.root.prefer_stable,
685            prefer_lowest: self.prefer_lowest,
686            platform_overrides: &self.config.platform,
687        }))
688    }
689
690    /// Full `composer update --no-install`: solve, dev package extraction,
691    /// lock data. Returns the lock JSON and the report of the first solve.
692    pub fn update(
693        &mut self,
694        manifest_text: &str,
695        filter: &PlatformRequirementFilter,
696    ) -> Result<(Value, SolveReport), SessionError> {
697        let trace = std::env::var_os("VIVACITY_TRACE").is_some();
698        let t = std::time::Instant::now();
699        let lap = |label: &str, t: &std::time::Instant| {
700            if trace {
701                eprintln!(
702                    "trace: {label:<22} {:>7.1} ms",
703                    t.elapsed().as_secs_f64() * 1000.0
704                );
705            }
706        };
707        let mut policy = self.policy();
708        let pool = self.create_filtered_pool()?;
709        for w in &pool.warnings {
710            eprintln!("Warning: {w}");
711        }
712        lap("pool", &t);
713        let pool = if std::env::var("COMPOSER_POOL_OPTIMIZER").as_deref() == Ok("0") {
714            pool
715        } else {
716            PoolOptimizer::new().optimize(&self.request, &pool, &self.arena, &mut policy)
717        };
718        lap("optimize", &t);
719        let mut report = self.solve(&pool, &mut policy, filter).map_err(|e| match e {
720            SolveError::Problems(p) => SessionError {
721                message: format!(
722                    "Your requirements could not be resolved to an installable set of packages ({} problem(s)).",
723                    p.len()
724                ),
725                kind: SessionErrorKind::Unsolvable,
726            },
727            SolveError::Bug(b) => SessionError::new(b),
728        })?;
729        lap("solve", &t);
730        // `ValidatingArrayLoader::validatePackage` on every kept package
731        // (`LockTransaction::setResultPackages`): a SecurityException stops
732        // the update.
733        for &idx in &report.transaction.all {
734            crate::lockfile::validate_package(&self.arena[idx]).map_err(SessionError::new)?;
735        }
736        drop(pool);
737        let mut transaction = std::mem::replace(&mut report.transaction, LockTransaction::empty());
738        self.extract_dev_packages(&mut transaction, &mut policy, filter)?;
739        lap("extract dev", &t);
740        let lock = self.lock_json(&transaction, manifest_text)?;
741        lap("lock data", &t);
742        report.transaction = transaction;
743        Ok((lock, report))
744    }
745
746    /// `createPool` with the PoolOptimizer (unless `COMPOSER_POOL_OPTIMIZER=0`),
747    /// like `Installer::doUpdate`.
748    /// `RepositorySet::findPackages($name)` on the `CompositeRepository` of
749    /// `require` (platform then project repositories, all merged) with a
750    /// `RepositorySet` reduced to `minimum-stability` (no flags);
751    /// `ignore_stability` means `ALLOW_UNACCEPTABLE_STABILITIES`.
752    pub fn find_packages_for_require(
753        &mut self,
754        name: &str,
755        ignore_stability: bool,
756    ) -> Result<Vec<usize>, SessionError> {
757        let name = name.to_lowercase();
758        let mut acceptable = BTreeMap::new();
759        let min = crate::version::stability_rank(&self.root.minimum_stability);
760        for st in ["stable", "RC", "beta", "alpha", "dev"] {
761            let rank = crate::version::stability_rank(st);
762            if ignore_stability || rank <= min {
763                acceptable.insert(st.to_owned(), rank);
764            }
765        }
766        let flags = BTreeMap::new();
767        let already = BTreeMap::new();
768        let map = vec![(name.clone(), Constraint::MatchAll)];
769        let mut found: Vec<usize> = Vec::new();
770        let (_, ids) = crate::pool::array_repository_load_packages(
771            &self.platform,
772            &map,
773            &acceptable,
774            &flags,
775            &already,
776            &self.arena,
777        );
778        found.extend(ids);
779        for (i, repo) in self.set.repositories.iter().enumerate() {
780            if let Repository::Composer(repo) = repo {
781                let (_, ids) = repo
782                    .load_packages(
783                        &map,
784                        &acceptable,
785                        &flags,
786                        &already,
787                        Origin::Repository(i),
788                        &mut self.arena,
789                    )
790                    .map_err(|e| SessionError::new(e.0))?;
791                found.extend(ids);
792            }
793        }
794        Ok(found)
795    }
796
797    pub fn create_optimized_pool(
798        &mut self,
799        policy: &mut DefaultPolicy,
800    ) -> Result<Pool, SessionError> {
801        let pool = self.create_filtered_pool()?;
802        if std::env::var("COMPOSER_POOL_OPTIMIZER").as_deref() == Ok("0") {
803            return Ok(pool);
804        }
805        Ok(PoolOptimizer::new().optimize(&self.request, &pool, &self.arena, policy))
806    }
807
808    /// `buildPool` up to the policy filters (advisories, lists), without the
809    /// optimizer; their warnings go into `pool.warnings`.
810    pub fn create_filtered_pool(&mut self) -> Result<Pool, SessionError> {
811        let mut pool = self.create_pool()?;
812        let before = pool.len();
813        let mut warnings = Vec::new();
814        pool = crate::pool_filters::security_advisory_filter(
815            pool,
816            &self.arena,
817            &self.set.repositories,
818            &self.request,
819            &self.policy_config,
820            &mut warnings,
821        )
822        .map_err(|e| SessionError::new(e.0))?;
823        pool = crate::pool_filters::filter_list_filter(
824            pool,
825            &self.arena,
826            &self.set.repositories,
827            &self.request,
828            &self.policy_config,
829            "update",
830            &mut warnings,
831        )
832        .map_err(|e| SessionError::new(e.0))?;
833        pool.warnings.extend(warnings);
834        if std::env::var_os("VIVACITY_TRACE").is_some() {
835            eprintln!(
836                "trace: policy filters      {before} → {} package versions ({} removed by lists)",
837                pool.len(),
838                pool.filter_list_removed
839                    .values()
840                    .map(Vec::len)
841                    .sum::<usize>()
842            );
843        }
844        Ok(pool)
845    }
846
847    /// `Solver::solve` on this pool; returns the transaction and the
848    /// decisions (pool literals, in order) with the rule set size.
849    pub fn solve(
850        &self,
851        pool: &Pool,
852        policy: &mut DefaultPolicy,
853        filter: &PlatformRequirementFilter,
854    ) -> Result<SolveReport, SolveError> {
855        let mut solver = Solver::new(pool, &self.arena);
856        let transaction = solver.solve(&self.request, policy, filter)?;
857        let decisions = solver.decisions.queue.iter().map(|d| d.literal).collect();
858        Ok(SolveReport {
859            learned: solver
860                .rules
861                .ids_of_type(crate::rule::RuleType::Learned)
862                .len(),
863            rules: solver.rule_set_size(),
864            decisions,
865            transaction,
866        })
867    }
868}
869
870/// Running the lock pool through the filter list filter in `install` scope
871/// (`Installer::doInstall` -> `createFilterListPoolFilter(BLOCK_SCOPE_INSTALL)`):
872/// Composer's problems for the removed locked versions, in lock order;
873/// empty when nothing blocks. Warnings (ignored unreachable repositories)
874/// are returned separately.
875pub fn install_policy_problems(
876    project_dir: &Path,
877    composer_home: Option<&Path>,
878    http: Option<HttpTransports>,
879    cache_repo_dir: Option<&Path>,
880    with_dev: bool,
881    no_blocking: bool,
882) -> Result<(Vec<String>, Vec<String>), SessionError> {
883    let manifest_text = std::fs::read_to_string(project_dir.join("composer.json"))
884        .map_err(|e| SessionError::new(format!("composer.json: {e}")))?;
885    let manifest: Value = serde_json::from_str(&manifest_text)
886        .map_err(|e| SessionError::new(format!("composer.json: {e}")))?;
887    let config = MergedConfig::load(&manifest, composer_home)?;
888    let mut policy = crate::policy_config::PolicyConfig::from_raw(&config.policy)
889        .map_err(|e| SessionError::new(e.0))?;
890    policy
891        .apply_no_blocking(no_blocking)
892        .map_err(|e| SessionError::new(e.0))?;
893    if !policy.malware_blocks("install") {
894        return Ok((Vec::new(), Vec::new()));
895    }
896    let lock_text = std::fs::read_to_string(project_dir.join("composer.lock"))
897        .map_err(|e| SessionError::new(format!("composer.lock: {e}")))?;
898    let lock: Value = serde_json::from_str(&lock_text)
899        .map_err(|e| SessionError::new(format!("composer.lock: {e}")))?;
900    // Only `composer` repositories carry lists; the other types (which
901    // `install` otherwise accepts) are left aside here.
902    let mut repositories: Vec<Repository> = Vec::new();
903    for repo in &config.repositories {
904        if repo.definition.get("type").and_then(Value::as_str) != Some("composer") {
905            continue;
906        }
907        // The constructor does no I/O: an error here is a configuration
908        // error, fatal as in Composer.
909        repositories.push(open_repository_with(
910            repo,
911            http.as_ref(),
912            cache_repo_dir,
913            true,
914        )?);
915    }
916    let mut arena: Vec<Package> = Vec::new();
917    let locked = crate::repository::locked_repository_with(&lock, &mut arena, with_dev)
918        .map_err(|e| SessionError::new(e.0))?;
919    let mut request = Request::new(Some(locked.clone()));
920    for &idx in &locked {
921        request.fix_locked_package(idx);
922    }
923    let pool = Pool::new(locked.clone(), Vec::new(), &arena);
924    let mut warnings = Vec::new();
925    let pool = crate::pool_filters::filter_list_filter(
926        pool,
927        &arena,
928        &repositories,
929        &request,
930        &policy,
931        "install",
932        &mut warnings,
933    )
934    .map_err(|e| SessionError::new(e.0))?;
935    let problems: Vec<String> = locked
936        .iter()
937        .map(|&idx| &arena[idx])
938        .filter(|p| pool.is_filter_list_removed(&p.name, &p.version))
939        .map(|p| crate::pool_filters::locked_removed_problem_text(&pool, p))
940        .collect();
941    Ok((problems, warnings))
942}
943
944/// `RepositoryManager::createRepository` restricted to `composer`
945/// repositories reachable over `file://` (the other types come with R3).
946fn open_repository(
947    repo: &RepoConfig,
948    http: Option<&HttpTransports>,
949    cache_repo_dir: Option<&Path>,
950) -> Result<Repository, SessionError> {
951    open_repository_with(repo, http, cache_repo_dir, false)
952}
953
954/// `for_policies`: a repository with `only`/`exclude`/`canonical`
955/// (`FilterRepository`) is accepted (the advisory and list paths honor
956/// `only`/`exclude`) where resolution still rejects it.
957fn open_repository_with(
958    repo: &RepoConfig,
959    http: Option<&HttpTransports>,
960    cache_repo_dir: Option<&Path>,
961    for_policies: bool,
962) -> Result<Repository, SessionError> {
963    let def = &repo.definition;
964    let kind = def.get("type").and_then(Value::as_str).ok_or_else(|| {
965        SessionError::new(format!(
966            "Repository \"{}\" ({def}) must have a type defined",
967            key_string(&repo.key)
968        ))
969    })?;
970    if kind != "composer" {
971        return Err(SessionError::new(format!(
972            "repository type \"{kind}\" is not supported by vivacity update yet ({})",
973            key_string(&repo.key)
974        )));
975    }
976    if !for_policies
977        && (def.get("only").is_some()
978            || def.get("exclude").is_some()
979            || def.get("canonical").is_some())
980    {
981        return Err(SessionError::new(format!(
982            "repository filters (only/exclude/canonical) are not supported by vivacity update yet ({})",
983            key_string(&repo.key)
984        )));
985    }
986    let url = def.get("url").and_then(Value::as_str).ok_or_else(|| {
987        SessionError::new(format!("repository {} has no url", key_string(&repo.key)))
988    })?;
989    let transport: Box<dyn crate::repository::Transport> = if url.starts_with("file://") {
990        Box::new(FileTransport)
991    } else if url.starts_with("http://") || url.starts_with("https://") || !url.contains("://") {
992        match http {
993            Some(h) => Box::new(HttpTransport {
994                fetch: h.0.clone(),
995                fetch_many: h.1.clone(),
996                post: h.2.clone(),
997            }),
998            None => {
999                return Err(SessionError::new(format!(
1000                    "remote composer repositories need a network transport ({url})"
1001                )))
1002            }
1003        }
1004    } else {
1005        return Err(SessionError::new(format!(
1006            "unsupported repository url scheme ({url})"
1007        )));
1008    };
1009    let mut repo = ComposerRepository::open(url, transport).map_err(|e| SessionError::new(e.0))?;
1010    if let Some(options) = def.get("options") {
1011        repo.options = options.clone();
1012    }
1013    repo.set_user_filter(def.get("filter"))
1014        .map_err(|e| SessionError::new(e.0))?;
1015    if for_policies {
1016        repo.set_name_filter(def.get("only"), def.get("exclude"))
1017            .map_err(|e| SessionError::new(e.0))?;
1018    }
1019    if let Some(dir) = cache_repo_dir {
1020        repo.cache = Some(crate::metacache::MetadataCache::new(dir, &repo.url));
1021    }
1022    Ok(Repository::Composer(Box::new(repo)))
1023}
1024
1025fn key_string(key: &RepoKey) -> String {
1026    match key {
1027        RepoKey::Named(n) => n.clone(),
1028        RepoKey::Indexed(i) => i.to_string(),
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035    use serde_json::json;
1036
1037    fn names(repos: &[RepoConfig]) -> Vec<String> {
1038        repos.iter().map(|r| key_string(&r.key)).collect()
1039    }
1040
1041    #[test]
1042    fn merges_repositories_like_composer_config() {
1043        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
1044        assert_eq!(names(&cfg.repositories), vec!["packagist.org"]);
1045        // global config: snapshot + packagist disabled.
1046        cfg.merge(&json!({"repositories": {"snapshot": {"type": "composer", "url": "file:///s"}, "packagist.org": false}}));
1047        assert_eq!(names(&cfg.repositories), vec!["snapshot"]);
1048        // composer.json: an indexed repository goes first.
1049        cfg.merge(&json!({"repositories": [{"type": "composer", "url": "https://packages.drupal.org/8"}]}));
1050        assert_eq!(names(&cfg.repositories), vec!["0", "snapshot"]);
1051        // two indexed ones while 0 exists: 1 takes its key, 0 is renumbered
1052        // (`$this->repositories[] = ...`); the new ones stay in front.
1053        cfg.merge(
1054            &json!({"repositories": [{"type": "vcs", "url": "a"}, {"type": "vcs", "url": "b"}]}),
1055        );
1056        assert_eq!(names(&cfg.repositories), vec!["2", "1", "0", "snapshot"]);
1057        assert_eq!(cfg.repositories[0].definition["url"], "a");
1058        assert_eq!(cfg.repositories[1].definition["url"], "b");
1059        assert_eq!(
1060            cfg.repositories[2].definition["url"],
1061            "https://packages.drupal.org/8"
1062        );
1063    }
1064
1065    #[test]
1066    fn packagist_url_disables_default() {
1067        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
1068        cfg.merge(
1069            &json!({"repositories": [{"type": "composer", "url": "https://repo.packagist.org"}]}),
1070        );
1071        assert_eq!(names(&cfg.repositories), vec!["0"]);
1072        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
1073        cfg.merge(&json!({"repositories": [{"packagist": false}]}));
1074        assert!(cfg.repositories.is_empty());
1075    }
1076}