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, BTreeSet};
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    /// Dry run of `require`/`remove`: the root package is patched in
257    /// memory, composer.json is not touched.
258    pub root_patch: Option<crate::root::RootPatch>,
259}
260
261impl UpdateOptions {
262    /// `Installer::setUpdateAllowList`: `strtolower` + `array_unique`.
263    pub fn partial(packages: &[String], transitive: crate::pool::UpdateMode) -> UpdateOptions {
264        let mut allow_list: Vec<String> = Vec::new();
265        for p in packages {
266            let l = p.to_lowercase();
267            if !allow_list.contains(&l) {
268                allow_list.push(l);
269            }
270        }
271        UpdateOptions {
272            allow_list,
273            transitive: Some(transitive),
274            ..Default::default()
275        }
276    }
277}
278
279/// Everything `Installer::doUpdate` has at hand right before `createPool`.
280pub struct UpdateSession {
281    pub arena: Vec<Package>,
282    pub root: RootPackage,
283    /// Arena indices of the fixed root (requires emptied) and of its alias.
284    pub fixed_root: usize,
285    pub fixed_root_alias: Option<usize>,
286    pub platform: Vec<usize>,
287    pub locked: Option<Vec<usize>>,
288    pub set: RepositorySet,
289    pub request: Request,
290    pub config: MergedConfig,
291    pub dev_mode: bool,
292    /// `--prefer-stable` / `--prefer-lowest` from the command line.
293    pub prefer_stable: bool,
294    pub prefer_lowest: bool,
295    /// `PHP_MAJOR.MINOR.RELEASE` of the probed PHP (`ext-*` rule of the
296    /// `VersionSelector`).
297    pub php_version: String,
298    /// The pool blocking policies (`createPolicyConfig`).
299    pub policy_config: crate::policy_config::PolicyConfig,
300    /// `XdebugHandler::getAllIniFiles()` of the probed PHP (extension hint
301    /// of an unsolvable set).
302    pub ini_files: Vec<String>,
303    /// `ext-*` loaded by the probed PHP, before `config.platform`.
304    pub loaded_extensions: BTreeSet<String>,
305    /// `Installer::$devMode` (`--no-dev` / `--update-no-dev` clear it):
306    /// only the `--no-dev` warning of an unsolvable set reads it.
307    pub installer_dev_mode: bool,
308}
309
310impl UpdateSession {
311    pub fn prepare(
312        project_dir: &Path,
313        composer_home: Option<&Path>,
314        dev_mode: bool,
315    ) -> Result<UpdateSession, SessionError> {
316        Self::prepare_with(project_dir, composer_home, dev_mode, None)
317    }
318
319    /// Like `prepare`, with a network transport for `https://` repositories.
320    pub fn prepare_with(
321        project_dir: &Path,
322        composer_home: Option<&Path>,
323        dev_mode: bool,
324        http: Option<HttpTransports>,
325    ) -> Result<UpdateSession, SessionError> {
326        Self::prepare_full(project_dir, composer_home, dev_mode, http, None)
327    }
328
329    /// Like `prepare_with`, with Composer's `cache-repo-dir` directory for
330    /// the metadata cache (read and written in Composer's format).
331    pub fn prepare_full(
332        project_dir: &Path,
333        composer_home: Option<&Path>,
334        dev_mode: bool,
335        http: Option<HttpTransports>,
336        cache_repo_dir: Option<&Path>,
337    ) -> Result<UpdateSession, SessionError> {
338        Self::prepare_update(
339            project_dir,
340            composer_home,
341            dev_mode,
342            http,
343            cache_repo_dir,
344            &UpdateOptions::default(),
345        )
346    }
347
348    /// Like `prepare_full`, with the allow list of a partial update
349    /// (`composer update a/b`).
350    pub fn prepare_update(
351        project_dir: &Path,
352        composer_home: Option<&Path>,
353        dev_mode: bool,
354        http: Option<HttpTransports>,
355        cache_repo_dir: Option<&Path>,
356        options: &UpdateOptions,
357    ) -> Result<UpdateSession, SessionError> {
358        let partial_update = !options.allow_list.is_empty();
359        let manifest_path = project_dir.join("composer.json");
360        let manifest_text = std::fs::read_to_string(&manifest_path)
361            .map_err(|e| SessionError::new(format!("{}: {e}", manifest_path.display())))?;
362        let manifest: Value = serde_json::from_str(&manifest_text)
363            .map_err(|e| SessionError::new(format!("{}: {e}", manifest_path.display())))?;
364        let config = MergedConfig::load(&manifest, composer_home)?;
365        let mut policy_config = crate::policy_config::PolicyConfig::from_raw(&config.policy)
366            .map_err(|e| SessionError::new(e.0))?;
367        policy_config
368            .apply_no_blocking(options.no_blocking)
369            .map_err(|e| SessionError::new(e.0))?;
370        let mut root =
371            RootPackage::load(&manifest, project_dir).map_err(|e| SessionError::new(e.0))?;
372        if let Some(patch) = &options.root_patch {
373            root.apply_patch(patch)
374                .map_err(|e| SessionError::new(e.0))?;
375        }
376        let probed = probe().map_err(|e| SessionError::new(e.0))?;
377        // `PHP_MAJOR_VERSION.PHP_MINOR_VERSION.PHP_RELEASE_VERSION` of the
378        // actual PHP (no `config.platform.php` here): the first three
379        // numbers of PHP_VERSION.
380        let php_version = probed
381            .iter()
382            .find(|p| p.get("name").and_then(Value::as_str) == Some("php"))
383            .and_then(|p| p.get("version").and_then(Value::as_str))
384            .map(|v| {
385                v.split(['.', '-', '+'])
386                    .take(3)
387                    .map(|part| part.trim_end_matches(|c: char| !c.is_ascii_digit()))
388                    .collect::<Vec<_>>()
389                    .join(".")
390            })
391            .unwrap_or_default();
392        let platform_pkgs =
393            platform_packages(&probed, &config.platform).map_err(|e| SessionError::new(e.0))?;
394
395        let mut arena: Vec<Package> = Vec::new();
396
397        // `$fixedRootPackage = clone $package; setRequires([]); setDevRequires([])`.
398        let mut fixed = root.package.clone();
399        fixed.requires = Default::default();
400        fixed.dev_requires = Default::default();
401        arena.push(fixed);
402        let fixed_root = 0;
403        let fixed_root_alias = root.branch_alias.as_ref().map(|(normalized, pretty)| {
404            let a = arena[fixed_root].alias(fixed_root, normalized, pretty);
405            arena.push(a);
406            arena.len() - 1
407        });
408        let root_members: Vec<usize> = fixed_root_alias
409            .into_iter()
410            .chain(std::iter::once(fixed_root))
411            .collect();
412
413        let mut platform: Vec<usize> = Vec::new();
414        for p in platform_pkgs {
415            arena.push(p);
416            platform.push(arena.len() - 1);
417        }
418
419        // `Locker::isLocked()` = file present and `isset($data['packages'])`;
420        // an unreadable lock is ignored for a full update (`doUpdate`
421        // swallows the ParsingException), fatal for a partial one.
422        let lock_path = project_dir.join("composer.lock");
423        let lock: Option<Value> = if lock_path.is_file() {
424            let text = std::fs::read_to_string(&lock_path)
425                .map_err(|e| SessionError::new(format!("{}: {e}", lock_path.display())))?;
426            match serde_json::from_str::<Value>(&text) {
427                Ok(v) if v.get("packages").is_some_and(|p| !p.is_null()) => Some(v),
428                Ok(_) => None,
429                Err(e) => {
430                    if partial_update {
431                        return Err(SessionError::new(format!(
432                            "\"{}\" does not contain valid JSON\n{e}",
433                            lock_path.display()
434                        )));
435                    }
436                    None
437                }
438            }
439        } else {
440            None
441        };
442        let locked = match &lock {
443            Some(v) => Some(locked_repository(v, &mut arena).map_err(|e| SessionError::new(e.0))?),
444            None => None,
445        };
446
447        // `$stabilityFlags[$package->getName()] = STABILITIES[parseStability($package->getVersion())]`:
448        // the version seen is that of the root alias if there is one.
449        let mut stability_flags = root.stability_flags.clone();
450        let root_version = match fixed_root_alias {
451            Some(a) => arena[a].version.clone(),
452            None => arena[fixed_root].version.clone(),
453        };
454        stability_flags.insert(
455            root.package.name.clone(),
456            stability_rank(parse_stability(&root_version)),
457        );
458
459        // `createRepositorySet(forUpdate)` and `requirePackagesForUpdate(..., true)`
460        // always take require + require-dev, `--no-dev` or not; and with a
461        // root alias, `$this->package` is the RootAliasPackage, whose
462        // `self.version` links target the alias version.
463        let mut root_requires: OrderedMap<Constraint> = OrderedMap::default();
464        let requires = match &root.branch_alias {
465            Some((normalized, pretty)) => {
466                let aliased = root.package.alias(0, normalized, pretty);
467                let mut all = aliased.requires.clone();
468                for l in aliased.dev_requires.iter() {
469                    all.insert(l.clone());
470                }
471                all
472            }
473            None => root.all_requires(),
474        };
475        for link in requires.iter() {
476            root_requires.insert(&link.target, link.constraint.clone());
477        }
478
479        let mut set = RepositorySet::new(
480            &root.minimum_stability,
481            stability_flags,
482            &root.aliases,
483            root.references.clone(),
484            root_requires,
485            Default::default(),
486        );
487        set.add_repository(Repository::Root(root_members));
488        set.add_repository(Repository::Platform(platform.clone()));
489        for repo in &config.repositories {
490            let origin = Origin::Repository(set.repositories.len());
491            set.add_repository(open_repository(
492                repo,
493                http.as_ref(),
494                cache_repo_dir,
495                project_dir,
496                origin,
497                &mut arena,
498            )?);
499        }
500        if let Some(ids) = &locked {
501            set.add_repository(Repository::Locked(ids.clone()));
502        }
503
504        // `Installer::run`: a partial update requires a lock.
505        if partial_update && locked.is_none() {
506            return Err(SessionError::new(
507                "Cannot update only a partial set of packages without a lock file present. Run `composer update` to generate a lock file.",
508            ));
509        }
510        let mut request = Request::new(locked.clone());
511        if partial_update {
512            request.set_update_allow_list(
513                options.allow_list.clone(),
514                options
515                    .transitive
516                    .unwrap_or(crate::pool::UpdateMode::OnlyListed),
517            );
518        }
519        if let Some(a) = fixed_root_alias {
520            request.fix_package(a);
521        }
522        request.fix_package(fixed_root);
523        for &p in &platform {
524            let provided = arena[fixed_root]
525                .provides
526                .get(&arena[p].name)
527                .map(|l| l.constraint.clone());
528            let provided_here = provided
529                .is_some_and(|c| c.matches(&Constraint::new(Op::Eq, arena[p].version.clone())));
530            if !provided_here {
531                request.fix_package(p);
532            }
533        }
534        for link in requires.iter() {
535            request.require_name_pretty(
536                &link.target,
537                Some(link.constraint.clone()),
538                &link.pretty_constraint,
539            )?;
540        }
541
542        Ok(UpdateSession {
543            arena,
544            root,
545            fixed_root,
546            fixed_root_alias,
547            platform,
548            locked,
549            set,
550            request,
551            config,
552            dev_mode,
553            prefer_stable: false,
554            prefer_lowest: false,
555            php_version,
556            policy_config,
557            ini_files: crate::platform::ini_files(&probed),
558            loaded_extensions: crate::platform::loaded_extensions(&probed),
559            installer_dev_mode: true,
560        })
561    }
562
563    pub fn create_pool(&mut self) -> Result<Pool, SessionError> {
564        Ok(self.set.create_pool(&mut self.request, &mut self.arena)?)
565    }
566
567    /// `Installer::createPolicy(true, ...)` without `--minimal-changes`.
568    pub fn policy(&self) -> DefaultPolicy {
569        DefaultPolicy::new(
570            self.prefer_stable || self.root.prefer_stable,
571            self.prefer_lowest,
572            None,
573        )
574    }
575
576    /// `Installer::extractDevPackages`: second solve without the
577    /// require-dev, on only the packages kept by the first, to classify
578    /// `packages` / `packages-dev`.
579    pub fn extract_dev_packages(
580        &mut self,
581        transaction: &mut LockTransaction,
582        policy: &mut DefaultPolicy,
583        filter: &PlatformRequirementFilter,
584    ) -> Result<(), SessionError> {
585        if self.root.package.dev_requires.is_empty() {
586            return Ok(());
587        }
588        // `$resultRepo`: each package reloaded from its dump (`load`, one at
589        // a time), branch aliases recreated -> [alias, base].
590        let dumps: Vec<Value> = transaction
591            .new_lock_packages(&self.arena, false)
592            .iter()
593            .map(|&idx| Value::Object(dump_package(&self.arena[idx])))
594            .collect();
595        let result_ids =
596            crate::loader::load_packages(&dumps, Origin::Result, &mut self.arena, false)
597                .map_err(|e| SessionError::new(e.0))?;
598        // createPoolWithAllPackages: root, platform, result, with the root
599        // aliases applied along the way.
600        let mut members: Vec<usize> = Vec::new();
601        members.extend(self.fixed_root_alias);
602        members.push(self.fixed_root);
603        members.extend(self.platform.iter().copied());
604        members.extend(result_ids);
605        let mut pool_packages: Vec<usize> = Vec::new();
606        for idx in members {
607            pool_packages.push(idx);
608            let (name, version) = (
609                self.arena[idx].name.clone(),
610                self.arena[idx].version.clone(),
611            );
612            if let Some((alias, alias_normalized)) = self
613                .set
614                .root_aliases
615                .get(&name)
616                .and_then(|m| m.get(&version))
617            {
618                let mut base = idx;
619                while let Some(b) = self.arena[base].alias_of {
620                    base = b;
621                }
622                let mut a = self.arena[base].alias(base, alias_normalized, alias);
623                a.root_package_alias = true;
624                a.origin = Origin::Detached;
625                self.arena.push(a);
626                pool_packages.push(self.arena.len() - 1);
627            }
628        }
629        let pool = Pool::new(pool_packages, Vec::new(), &self.arena);
630        // createRequest (without lock) + requirePackagesForUpdate(..., false).
631        let mut request = Request::new(None);
632        if let Some(a) = self.fixed_root_alias {
633            request.fix_package(a);
634        }
635        request.fix_package(self.fixed_root);
636        for &p in &self.platform {
637            let provided = self.arena[self.fixed_root]
638                .provides
639                .get(&self.arena[p].name)
640                .map(|l| l.constraint.clone());
641            let provided_here = provided.is_some_and(|c| {
642                c.matches(&Constraint::new(Op::Eq, self.arena[p].version.clone()))
643            });
644            if !provided_here {
645                request.fix_package(p);
646            }
647        }
648        let requires = match &self.root.branch_alias {
649            Some((normalized, pretty)) => self.root.package.alias(0, normalized, pretty).requires,
650            None => self.root.package.requires.clone(),
651        };
652        for link in requires.iter() {
653            request.require_name_pretty(
654                &link.target,
655                Some(link.constraint.clone()),
656                &link.pretty_constraint,
657            )?;
658        }
659        let mut solver = Solver::new(&pool, &self.arena);
660        let non_dev = match solver.solve(&request, policy, filter) {
661            Ok(t) => t,
662            Err(SolveError::Problems(problems)) => {
663                drop(solver);
664                // `Installer::extractDevPackages`: `$isDevExtraction`, on a
665                // request without lock built for this solve.
666                let pretty = {
667                    let mut ctx = crate::problem::MessageContext {
668                        arena: &mut self.arena,
669                        set: &self.set,
670                        pool: &pool,
671                        request: &request,
672                        is_verbose: false,
673                        ini_files: &self.ini_files,
674                        loaded_extensions: &self.loaded_extensions,
675                    };
676                    crate::problem::pretty_string(&mut ctx, &problems, true)
677                };
678                return Err(SessionError {
679                    message: format!("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.\n{pretty}"),
680                    kind: SessionErrorKind::Unsolvable,
681                });
682            }
683            Err(SolveError::Bug(b)) => return Err(SessionError::new(b)),
684        };
685        transaction.set_non_dev_packages(&self.arena, &non_dev);
686        Ok(())
687    }
688
689    /// `extractPlatformRequirements($links)`.
690    fn platform_requirements(links: &crate::package::Links) -> Map<String, Value> {
691        let mut out = Map::new();
692        for l in links.iter() {
693            if crate::platform::is_platform_package(&l.target) {
694                out.insert(l.target.clone(), Value::String(l.pretty_constraint.clone()));
695            }
696        }
697        out
698    }
699
700    /// `Locker::setLockData(...)`: the lock JSON to write.
701    pub fn lock_json(
702        &self,
703        transaction: &LockTransaction,
704        manifest_text: &str,
705    ) -> Result<Value, SessionError> {
706        let content_hash = vivacity_core::content_hash::content_hash(manifest_text)
707            .map_err(|e| SessionError::new(e.to_string()))?;
708        let packages = lock_packages(
709            &self.arena,
710            &transaction.new_lock_packages(&self.arena, false),
711        )
712        .map_err(SessionError::new)?;
713        let packages_dev = lock_packages(
714            &self.arena,
715            &transaction.new_lock_packages(&self.arena, true),
716        )
717        .map_err(SessionError::new)?;
718        let (requires, dev_requires) = match &self.root.branch_alias {
719            Some((normalized, pretty)) => {
720                let a = self.root.package.alias(0, normalized, pretty);
721                (a.requires, a.dev_requires)
722            }
723            None => (
724                self.root.package.requires.clone(),
725                self.root.package.dev_requires.clone(),
726            ),
727        };
728        Ok(lock_data(LockInput {
729            content_hash: &content_hash,
730            packages,
731            packages_dev: Some(packages_dev),
732            platform: Self::platform_requirements(&requires),
733            platform_dev: Self::platform_requirements(&dev_requires),
734            aliases: &transaction.aliases(&self.arena, &self.root.aliases),
735            minimum_stability: &self.root.minimum_stability,
736            stability_flags: &self.root.stability_flags,
737            prefer_stable: self.prefer_stable || self.root.prefer_stable,
738            prefer_lowest: self.prefer_lowest,
739            platform_overrides: &self.config.platform,
740        }))
741    }
742
743    /// Full `composer update --no-install`: solve, dev package extraction,
744    /// lock data. Returns the lock JSON and the report of the first solve.
745    pub fn update(
746        &mut self,
747        manifest_text: &str,
748        filter: &PlatformRequirementFilter,
749    ) -> Result<(Value, SolveReport), SessionError> {
750        let trace = std::env::var_os("VIVACITY_TRACE").is_some();
751        let t = std::time::Instant::now();
752        let lap = |label: &str, t: &std::time::Instant| {
753            if trace {
754                eprintln!(
755                    "trace: {label:<22} {:>7.1} ms",
756                    t.elapsed().as_secs_f64() * 1000.0
757                );
758            }
759        };
760        let mut policy = self.policy();
761        let pool = self.create_filtered_pool()?;
762        for w in &pool.warnings {
763            eprintln!("{w}");
764        }
765        lap("pool", &t);
766        let pool = if std::env::var("COMPOSER_POOL_OPTIMIZER").as_deref() == Ok("0") {
767            pool
768        } else {
769            PoolOptimizer::new().optimize(&self.request, &pool, &self.arena, &mut policy)
770        };
771        lap("optimize", &t);
772        let mut report = match self.solve(&pool, &mut policy, filter) {
773            Ok(r) => r,
774            Err(SolveError::Problems(problems)) => {
775                // `Installer::doUpdate`: the headline, the pretty problems,
776                // the `--no-dev` warning.
777                let pretty = self.pretty_problems(&pool, &problems, false);
778                let mut message = format!(
779                    "Your requirements could not be resolved to an installable set of packages.\n{pretty}"
780                );
781                if !self.installer_dev_mode {
782                    message.push_str("\nRunning update with --no-dev does not mean require-dev is ignored, it just means the packages will not be installed. If dev requirements are blocking the update you have to resolve those problems.");
783                }
784                return Err(SessionError {
785                    message,
786                    kind: SessionErrorKind::Unsolvable,
787                });
788            }
789            Err(SolveError::Bug(b)) => return Err(SessionError::new(b)),
790        };
791        lap("solve", &t);
792        // `ValidatingArrayLoader::validatePackage` on every kept package
793        // (`LockTransaction::setResultPackages`): a SecurityException stops
794        // the update.
795        for &idx in &report.transaction.all {
796            crate::lockfile::validate_package(&self.arena[idx]).map_err(SessionError::new)?;
797        }
798        drop(pool);
799        let mut transaction = std::mem::replace(&mut report.transaction, LockTransaction::empty());
800        self.extract_dev_packages(&mut transaction, &mut policy, filter)?;
801        lap("extract dev", &t);
802        let lock = self.lock_json(&transaction, manifest_text)?;
803        lap("lock data", &t);
804        report.transaction = transaction;
805        Ok((lock, report))
806    }
807
808    /// `SolverProblemsException::getPrettyString` for problems found on
809    /// `pool` with this session's request.
810    pub fn pretty_problems(
811        &mut self,
812        pool: &Pool,
813        problems: &[crate::solver::SolvedProblem],
814        is_dev_extraction: bool,
815    ) -> String {
816        let mut ctx = crate::problem::MessageContext {
817            arena: &mut self.arena,
818            set: &self.set,
819            pool,
820            request: &self.request,
821            is_verbose: false,
822            ini_files: &self.ini_files,
823            loaded_extensions: &self.loaded_extensions,
824        };
825        crate::problem::pretty_string(&mut ctx, problems, is_dev_extraction)
826    }
827
828    /// `createPool` with the PoolOptimizer (unless `COMPOSER_POOL_OPTIMIZER=0`),
829    /// like `Installer::doUpdate`.
830    /// `RepositorySet::findPackages($name)` on the `CompositeRepository` of
831    /// `require` (platform then project repositories, all merged) with a
832    /// `RepositorySet` reduced to `minimum-stability` (no flags);
833    /// `ignore_stability` means `ALLOW_UNACCEPTABLE_STABILITIES`.
834    pub fn find_packages_for_require(
835        &mut self,
836        name: &str,
837        ignore_stability: bool,
838    ) -> Result<Vec<usize>, SessionError> {
839        let name = name.to_lowercase();
840        let mut acceptable = BTreeMap::new();
841        let min = crate::version::stability_rank(&self.root.minimum_stability);
842        for st in ["stable", "RC", "beta", "alpha", "dev"] {
843            let rank = crate::version::stability_rank(st);
844            if ignore_stability || rank <= min {
845                acceptable.insert(st.to_owned(), rank);
846            }
847        }
848        let flags = BTreeMap::new();
849        let already = BTreeMap::new();
850        let map = vec![(name.clone(), Constraint::MatchAll)];
851        let mut found: Vec<usize> = Vec::new();
852        let (_, ids) = crate::pool::array_repository_load_packages(
853            &self.platform,
854            &map,
855            &acceptable,
856            &flags,
857            &already,
858            &self.arena,
859        );
860        found.extend(ids);
861        for (i, repo) in self.set.repositories.iter().enumerate() {
862            if let Repository::Composer(repo) = repo {
863                let (_, ids) = repo
864                    .load_packages(
865                        &map,
866                        &acceptable,
867                        &flags,
868                        &already,
869                        Origin::Repository(i),
870                        &mut self.arena,
871                    )
872                    .map_err(|e| SessionError::new(e.0))?;
873                found.extend(ids);
874            }
875        }
876        Ok(found)
877    }
878
879    pub fn create_optimized_pool(
880        &mut self,
881        policy: &mut DefaultPolicy,
882    ) -> Result<Pool, SessionError> {
883        let pool = self.create_filtered_pool()?;
884        if std::env::var("COMPOSER_POOL_OPTIMIZER").as_deref() == Ok("0") {
885            return Ok(pool);
886        }
887        Ok(PoolOptimizer::new().optimize(&self.request, &pool, &self.arena, policy))
888    }
889
890    /// `buildPool` up to the policy filters (advisories, lists), without the
891    /// optimizer; their warnings go into `pool.warnings`.
892    pub fn create_filtered_pool(&mut self) -> Result<Pool, SessionError> {
893        let mut pool = self.create_pool()?;
894        let before = pool.len();
895        let mut warnings = Vec::new();
896        pool = crate::pool_filters::security_advisory_filter(
897            pool,
898            &self.arena,
899            &self.set.repositories,
900            &self.request,
901            &self.policy_config,
902            &mut warnings,
903        )
904        .map_err(|e| SessionError::new(e.0))?;
905        pool = crate::pool_filters::filter_list_filter(
906            pool,
907            &self.arena,
908            &self.set.repositories,
909            &self.request,
910            &self.policy_config,
911            "update",
912            &mut warnings,
913        )
914        .map_err(|e| SessionError::new(e.0))?;
915        pool.warnings.extend(warnings);
916        if std::env::var_os("VIVACITY_TRACE").is_some() {
917            eprintln!(
918                "trace: policy filters      {before} → {} package versions ({} removed by lists)",
919                pool.len(),
920                pool.filter_list_removed
921                    .values()
922                    .map(Vec::len)
923                    .sum::<usize>()
924            );
925        }
926        Ok(pool)
927    }
928
929    /// `Solver::solve` on this pool; returns the transaction and the
930    /// decisions (pool literals, in order) with the rule set size.
931    pub fn solve(
932        &self,
933        pool: &Pool,
934        policy: &mut DefaultPolicy,
935        filter: &PlatformRequirementFilter,
936    ) -> Result<SolveReport, SolveError> {
937        let mut solver = Solver::new(pool, &self.arena);
938        let transaction = solver.solve(&self.request, policy, filter)?;
939        let decisions = solver.decisions.queue.iter().map(|d| d.literal).collect();
940        Ok(SolveReport {
941            learned: solver
942                .rules
943                .ids_of_type(crate::rule::RuleType::Learned)
944                .len(),
945            rules: solver.rule_set_size(),
946            decisions,
947            transaction,
948        })
949    }
950}
951
952/// Running the lock pool through the filter list filter in `install` scope
953/// (`Installer::doInstall` -> `createFilterListPoolFilter(BLOCK_SCOPE_INSTALL)`):
954/// Composer's problems for the removed locked versions, in lock order;
955/// empty when nothing blocks. Warnings (ignored unreachable repositories)
956/// are returned separately.
957pub fn install_policy_problems(
958    project_dir: &Path,
959    composer_home: Option<&Path>,
960    http: Option<HttpTransports>,
961    cache_repo_dir: Option<&Path>,
962    with_dev: bool,
963    no_blocking: bool,
964) -> Result<(Vec<String>, Vec<String>), SessionError> {
965    let manifest_text = std::fs::read_to_string(project_dir.join("composer.json"))
966        .map_err(|e| SessionError::new(format!("composer.json: {e}")))?;
967    let manifest: Value = serde_json::from_str(&manifest_text)
968        .map_err(|e| SessionError::new(format!("composer.json: {e}")))?;
969    let config = MergedConfig::load(&manifest, composer_home)?;
970    let mut policy = crate::policy_config::PolicyConfig::from_raw(&config.policy)
971        .map_err(|e| SessionError::new(e.0))?;
972    policy
973        .apply_no_blocking(no_blocking)
974        .map_err(|e| SessionError::new(e.0))?;
975    if !policy.malware_blocks("install") {
976        return Ok((Vec::new(), Vec::new()));
977    }
978    let lock_text = std::fs::read_to_string(project_dir.join("composer.lock"))
979        .map_err(|e| SessionError::new(format!("composer.lock: {e}")))?;
980    let lock: Value = serde_json::from_str(&lock_text)
981        .map_err(|e| SessionError::new(format!("composer.lock: {e}")))?;
982    // Only `composer` repositories carry lists; the other types (which
983    // `install` otherwise accepts) are left aside here.
984    let mut repositories: Vec<Repository> = Vec::new();
985    for repo in &config.repositories {
986        if repo.definition.get("type").and_then(Value::as_str) != Some("composer") {
987            continue;
988        }
989        // The constructor does no I/O: an error here is a configuration
990        // error, fatal as in Composer.
991        repositories.push(open_repository_with(
992            repo,
993            http.as_ref(),
994            cache_repo_dir,
995            true,
996        )?);
997    }
998    let mut arena: Vec<Package> = Vec::new();
999    let locked = crate::repository::locked_repository_with(&lock, &mut arena, with_dev)
1000        .map_err(|e| SessionError::new(e.0))?;
1001    let mut request = Request::new(Some(locked.clone()));
1002    for &idx in &locked {
1003        request.fix_locked_package(idx);
1004    }
1005    let pool = Pool::new(locked.clone(), Vec::new(), &arena);
1006    let mut warnings = Vec::new();
1007    let pool = crate::pool_filters::filter_list_filter(
1008        pool,
1009        &arena,
1010        &repositories,
1011        &request,
1012        &policy,
1013        "install",
1014        &mut warnings,
1015    )
1016    .map_err(|e| SessionError::new(e.0))?;
1017    let problems: Vec<String> = locked
1018        .iter()
1019        .map(|&idx| &arena[idx])
1020        .filter(|p| pool.is_filter_list_removed(&p.name, &p.version))
1021        .map(|p| crate::pool_filters::locked_removed_problem_text(&pool, p))
1022        .collect();
1023    Ok((problems, warnings))
1024}
1025
1026/// `RepositoryManager::createRepository` for the `composer` and `path`
1027/// types (`vcs` and the others are not supported yet). A `path` repository
1028/// reads its packages now, into the arena, as `origin`.
1029fn open_repository(
1030    repo: &RepoConfig,
1031    http: Option<&HttpTransports>,
1032    cache_repo_dir: Option<&Path>,
1033    project_dir: &Path,
1034    origin: Origin,
1035    arena: &mut Vec<Package>,
1036) -> Result<Repository, SessionError> {
1037    if repo.definition.get("type").and_then(Value::as_str) == Some("path") {
1038        if repo.definition.get("only").is_some()
1039            || repo.definition.get("exclude").is_some()
1040            || repo.definition.get("canonical").is_some()
1041        {
1042            return Err(SessionError::new(format!(
1043                "repository filters (only/exclude/canonical) are not supported by vivacity update yet ({})",
1044                key_string(&repo.key)
1045            )));
1046        }
1047        let path_repo = crate::path_repo::open(&repo.definition, project_dir, origin, arena)
1048            .map_err(|e| SessionError::new(e.0))?;
1049        return Ok(Repository::Path(path_repo));
1050    }
1051    open_repository_with(repo, http, cache_repo_dir, false)
1052}
1053
1054/// `for_policies`: a repository with `only`/`exclude`/`canonical`
1055/// (`FilterRepository`) is accepted (the advisory and list paths honor
1056/// `only`/`exclude`) where resolution still rejects it.
1057fn open_repository_with(
1058    repo: &RepoConfig,
1059    http: Option<&HttpTransports>,
1060    cache_repo_dir: Option<&Path>,
1061    for_policies: bool,
1062) -> Result<Repository, SessionError> {
1063    let def = &repo.definition;
1064    let kind = def.get("type").and_then(Value::as_str).ok_or_else(|| {
1065        SessionError::new(format!(
1066            "Repository \"{}\" ({def}) must have a type defined",
1067            key_string(&repo.key)
1068        ))
1069    })?;
1070    if kind != "composer" {
1071        return Err(SessionError::new(format!(
1072            "repository type \"{kind}\" is not supported by vivacity update yet ({})",
1073            key_string(&repo.key)
1074        )));
1075    }
1076    if !for_policies
1077        && (def.get("only").is_some()
1078            || def.get("exclude").is_some()
1079            || def.get("canonical").is_some())
1080    {
1081        return Err(SessionError::new(format!(
1082            "repository filters (only/exclude/canonical) are not supported by vivacity update yet ({})",
1083            key_string(&repo.key)
1084        )));
1085    }
1086    let url = def.get("url").and_then(Value::as_str).ok_or_else(|| {
1087        SessionError::new(format!("repository {} has no url", key_string(&repo.key)))
1088    })?;
1089    let transport: Box<dyn crate::repository::Transport> = if url.starts_with("file://") {
1090        Box::new(FileTransport)
1091    } else if url.starts_with("http://") || url.starts_with("https://") || !url.contains("://") {
1092        match http {
1093            Some(h) => Box::new(HttpTransport {
1094                fetch: h.0.clone(),
1095                fetch_many: h.1.clone(),
1096                post: h.2.clone(),
1097            }),
1098            None => {
1099                return Err(SessionError::new(format!(
1100                    "remote composer repositories need a network transport ({url})"
1101                )))
1102            }
1103        }
1104    } else {
1105        return Err(SessionError::new(format!(
1106            "unsupported repository url scheme ({url})"
1107        )));
1108    };
1109    let mut repo = ComposerRepository::open(url, transport).map_err(|e| SessionError::new(e.0))?;
1110    if let Some(options) = def.get("options") {
1111        repo.options = options.clone();
1112    }
1113    repo.set_user_filter(def.get("filter"))
1114        .map_err(|e| SessionError::new(e.0))?;
1115    if for_policies {
1116        repo.set_name_filter(def.get("only"), def.get("exclude"))
1117            .map_err(|e| SessionError::new(e.0))?;
1118    }
1119    if let Some(dir) = cache_repo_dir {
1120        repo.cache = Some(crate::metacache::MetadataCache::new(dir, &repo.url));
1121    }
1122    Ok(Repository::Composer(Box::new(repo)))
1123}
1124
1125fn key_string(key: &RepoKey) -> String {
1126    match key {
1127        RepoKey::Named(n) => n.clone(),
1128        RepoKey::Indexed(i) => i.to_string(),
1129    }
1130}
1131
1132#[cfg(test)]
1133mod tests {
1134    use super::*;
1135    use serde_json::json;
1136
1137    fn names(repos: &[RepoConfig]) -> Vec<String> {
1138        repos.iter().map(|r| key_string(&r.key)).collect()
1139    }
1140
1141    #[test]
1142    fn merges_repositories_like_composer_config() {
1143        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
1144        assert_eq!(names(&cfg.repositories), vec!["packagist.org"]);
1145        // global config: snapshot + packagist disabled.
1146        cfg.merge(&json!({"repositories": {"snapshot": {"type": "composer", "url": "file:///s"}, "packagist.org": false}}));
1147        assert_eq!(names(&cfg.repositories), vec!["snapshot"]);
1148        // composer.json: an indexed repository goes first.
1149        cfg.merge(&json!({"repositories": [{"type": "composer", "url": "https://packages.drupal.org/8"}]}));
1150        assert_eq!(names(&cfg.repositories), vec!["0", "snapshot"]);
1151        // two indexed ones while 0 exists: 1 takes its key, 0 is renumbered
1152        // (`$this->repositories[] = ...`); the new ones stay in front.
1153        cfg.merge(
1154            &json!({"repositories": [{"type": "vcs", "url": "a"}, {"type": "vcs", "url": "b"}]}),
1155        );
1156        assert_eq!(names(&cfg.repositories), vec!["2", "1", "0", "snapshot"]);
1157        assert_eq!(cfg.repositories[0].definition["url"], "a");
1158        assert_eq!(cfg.repositories[1].definition["url"], "b");
1159        assert_eq!(
1160            cfg.repositories[2].definition["url"],
1161            "https://packages.drupal.org/8"
1162        );
1163    }
1164
1165    #[test]
1166    fn packagist_url_disables_default() {
1167        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
1168        cfg.merge(
1169            &json!({"repositories": [{"type": "composer", "url": "https://repo.packagist.org"}]}),
1170        );
1171        assert_eq!(names(&cfg.repositories), vec!["0"]);
1172        let mut cfg = MergedConfig::load(&json!({}), None).unwrap();
1173        cfg.merge(&json!({"repositories": [{"packagist": false}]}));
1174        assert!(cfg.repositories.is_empty());
1175    }
1176}