Skip to main content

vivacity_core/
lock.rs

1//! Reading composer.lock. Minimal typed view over the raw JSON:
2//! `installed.json`/`installed.php` will have to serve the entries back
3//! **unchanged**, so each package keeps its raw value (`raw`) and only exposes
4//! as typed fields what the installer needs.
5
6use crate::error::{Error, Result};
7use serde_json::{Map, Value};
8use std::path::Path;
9
10#[derive(Debug, Clone)]
11pub struct Lock {
12    pub content_hash: Option<String>,
13    pub packages: Vec<LockPackage>,
14    pub packages_dev: Vec<LockPackage>,
15    /// Project platform constraints (php, ext-*, lib-*) -> constraint.
16    pub platform: Vec<(String, String)>,
17    pub platform_dev: Vec<(String, String)>,
18    pub plugin_api_version: Option<String>,
19    pub aliases: Vec<Value>,
20}
21
22#[derive(Debug, Clone)]
23pub struct LockPackage {
24    pub raw: Map<String, Value>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DistKind {
29    Zip,
30    /// `TarDownloader` (asset-packagist's npm tarballs).
31    Tar,
32    /// A `path` repository package: `dist.url` is the source directory.
33    Path,
34    Other,
35    Missing,
36}
37
38impl LockPackage {
39    fn str_field(&self, key: &str) -> Option<&str> {
40        self.raw.get(key).and_then(Value::as_str)
41    }
42
43    pub fn name(&self) -> &str {
44        self.str_field("name").unwrap_or("")
45    }
46
47    pub fn version(&self) -> &str {
48        self.str_field("version").unwrap_or("")
49    }
50
51    /// Package type, Composer default: "library".
52    pub fn package_type(&self) -> &str {
53        self.str_field("type").unwrap_or("library")
54    }
55
56    pub fn dist_url(&self) -> Option<&str> {
57        self.raw.get("dist")?.get("url")?.as_str()
58    }
59
60    /// `Package::getDistUrls`: a dist url holding `%` goes through
61    /// `ComposerMirror::processUrl` — `%package%`, `%version%` (normalized),
62    /// `%reference%`, `%type%` and `%prettyVersion%` are substituted (a
63    /// `package` repository entry such as
64    /// `https://host/archive/%prettyVersion%.zip`).
65    pub fn dist_url_expanded(&self) -> Option<String> {
66        let url = self.dist_url()?;
67        if !url.contains('%') {
68            return Some(url.to_owned());
69        }
70        let version = crate::version::normalize_pretty(self.version())
71            .unwrap_or_else(|_| self.version().to_owned());
72        let kind = self
73            .raw
74            .get("dist")
75            .and_then(|d| d.get("type"))
76            .and_then(Value::as_str)
77            .unwrap_or("");
78        Some(
79            url.replace("%package%", self.name())
80                .replace("%version%", &version)
81                .replace("%reference%", self.dist_reference().unwrap_or(""))
82                .replace("%type%", kind)
83                .replace("%prettyVersion%", self.version()),
84        )
85    }
86
87    pub fn dist_reference(&self) -> Option<&str> {
88        self.raw.get("dist")?.get("reference")?.as_str()
89    }
90
91    /// sha1 shasum of the dist if non-empty (often empty on Packagist).
92    pub fn dist_shasum(&self) -> Option<&str> {
93        self.raw
94            .get("dist")?
95            .get("shasum")?
96            .as_str()
97            .filter(|s| !s.is_empty())
98    }
99
100    /// What identifies the dist's content in the store: its reference,
101    /// or — a `tar` dist from asset-packagist has none — the sha1 of its
102    /// URL, as Composer's own cache keys it (two URLs for one version must
103    /// not share an entry).
104    pub fn store_reference(&self) -> Option<String> {
105        match self.dist_reference() {
106            Some(r) => Some(r.to_owned()),
107            None => self.dist_url().map(|u| {
108                use sha1::Digest as _;
109                sha1::Sha1::digest(u.as_bytes())
110                    .iter()
111                    .map(|b| format!("{b:02x}"))
112                    .collect()
113            }),
114        }
115    }
116
117    pub fn dist_kind(&self) -> DistKind {
118        match self
119            .raw
120            .get("dist")
121            .and_then(|d| d.get("type"))
122            .and_then(Value::as_str)
123        {
124            Some("zip") => DistKind::Zip,
125            Some("tar") => DistKind::Tar,
126            Some("path") => DistKind::Path,
127            Some(_) => DistKind::Other,
128            None => DistKind::Missing,
129        }
130    }
131
132    /// `target-dir` (legacy PSR-0): the package installs into
133    /// vendor/<name>/<target-dir>.
134    pub fn target_dir(&self) -> Option<&str> {
135        self.str_field("target-dir")
136            .map(|t| t.trim_matches('/'))
137            .filter(|t| !t.is_empty())
138    }
139
140    /// Install path relative to vendor/ (`name` or `name/target-dir`).
141    pub fn install_subpath(&self) -> String {
142        match self.target_dir() {
143            Some(t) => format!("{}/{}", self.name(), t),
144            None => self.name().to_owned(),
145        }
146    }
147
148    pub fn is_metapackage(&self) -> bool {
149        self.package_type() == "metapackage"
150    }
151
152    /// Installed nowhere: a metapackage, or a `symfony-pack` when Flex is
153    /// active (its `SymfonyPackInstaller extends MetapackageInstaller`).
154    pub fn is_virtual(&self, flex_packs: bool) -> bool {
155        self.is_metapackage() || (flex_packs && self.package_type() == "symfony-pack")
156    }
157
158    pub fn bins(&self) -> Vec<&str> {
159        self.raw
160            .get("bin")
161            .and_then(Value::as_array)
162            .map(|a| a.iter().filter_map(Value::as_str).collect())
163            .unwrap_or_default()
164    }
165}
166
167fn parse_packages(v: Option<&Value>) -> Vec<LockPackage> {
168    v.and_then(Value::as_array)
169        .map(|a| {
170            a.iter()
171                .filter_map(|p| p.as_object())
172                .map(|m| LockPackage { raw: m.clone() })
173                .collect()
174        })
175        .unwrap_or_default()
176}
177
178/// `platform` is `{}` or `{"php": ">=8.2", "ext-mbstring": "*"}` and, PHP
179/// encoding quirk, sometimes `[]` (empty array) when there is nothing.
180fn parse_platform(v: Option<&Value>) -> Vec<(String, String)> {
181    v.and_then(Value::as_object)
182        .map(|m| {
183            m.iter()
184                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
185                .collect()
186        })
187        .unwrap_or_default()
188}
189
190impl Lock {
191    pub fn parse(text: &str) -> Result<Self> {
192        let v: Value = serde_json::from_str(text).map_err(|source| Error::Json {
193            context: "composer.lock".to_owned(),
194            source,
195        })?;
196        Ok(Self::from_value(&v))
197    }
198
199    /// From an already parsed lock (a caller that also needs the `Value`
200    /// parses once).
201    pub fn from_value(v: &Value) -> Self {
202        Lock {
203            content_hash: v
204                .get("content-hash")
205                .and_then(Value::as_str)
206                .map(str::to_owned),
207            packages: parse_packages(v.get("packages")),
208            packages_dev: parse_packages(v.get("packages-dev")),
209            platform: parse_platform(v.get("platform")),
210            platform_dev: parse_platform(v.get("platform-dev")),
211            plugin_api_version: v
212                .get("plugin-api-version")
213                .and_then(Value::as_str)
214                .map(str::to_owned),
215            aliases: v
216                .get("aliases")
217                .and_then(Value::as_array)
218                .cloned()
219                .unwrap_or_default(),
220        }
221    }
222
223    pub fn read(path: &Path) -> Result<Self> {
224        let text = std::fs::read_to_string(path).map_err(|source| Error::ReadFile {
225            path: path.to_path_buf(),
226            source,
227        })?;
228        Self::parse(&text)
229    }
230
231    /// Packages to install according to --no-dev.
232    /// Whether `symfony-pack` packages are virtual for this install: Flex
233    /// is in the lock, plugins are enabled and Flex is allowed (Composer
234    /// loads it and it registers the pack installer).
235    pub fn flex_packs(&self, root_manifest: &Value, with_dev: bool, plugins_enabled: bool) -> bool {
236        plugins_enabled
237            && self
238                .wanted_packages(with_dev)
239                .any(|p| p.name() == "symfony/flex")
240            && matches!(
241                crate::layout::plugin_allowed(root_manifest, "symfony/flex"),
242                crate::layout::PluginVerdict::Allowed
243            )
244    }
245
246    pub fn wanted_packages(&self, with_dev: bool) -> impl Iterator<Item = &LockPackage> {
247        self.packages
248            .iter()
249            .chain(
250                self.packages_dev
251                    .iter()
252                    .take(if with_dev { usize::MAX } else { 0 }),
253            )
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn parses_minimal_lock() {
263        let lock = Lock::parse(
264            r#"{"content-hash":"abc","packages":[{"name":"a/b","version":"1.0.0",
265                "dist":{"type":"zip","url":"https://x/y.zip","reference":"deadbeef","shasum":""},
266                "type":"library","bin":["bin/tool"]}],
267               "packages-dev":[],"platform":{"php":">=8.1"},"platform-dev":[]}"#,
268        )
269        .expect("parse");
270        assert_eq!(lock.content_hash.as_deref(), Some("abc"));
271        let p = &lock.packages[0];
272        assert_eq!(p.name(), "a/b");
273        assert_eq!(p.dist_kind(), DistKind::Zip);
274        assert_eq!(p.dist_shasum(), None); // empty -> None
275        assert_eq!(p.bins(), vec!["bin/tool"]);
276        assert_eq!(lock.platform, vec![("php".to_owned(), ">=8.1".to_owned())]);
277        assert_eq!(lock.wanted_packages(false).count(), 1);
278    }
279
280    #[test]
281    fn empty_platform_as_array() {
282        let lock = Lock::parse(r#"{"packages":[],"platform":[]}"#).expect("parse");
283        assert!(lock.platform.is_empty());
284    }
285}
286
287#[cfg(test)]
288mod placeholder_tests {
289    use super::*;
290
291    #[test]
292    fn dist_url_placeholders_like_composer_mirror() {
293        let p = LockPackage {
294            raw: serde_json::from_str(r#"{"name": "ssddanbrown/asserthtml", "version": "v3.2.0",
295                "dist": {"type": "zip", "url": "https://codeberg.org/api/v1/repos/%package%/archive/%prettyVersion%.zip?r=%reference%&t=%type%&v=%version%", "reference": "0811b5c"}}"#).unwrap(),
296        };
297        assert_eq!(
298            p.dist_url_expanded().unwrap(),
299            "https://codeberg.org/api/v1/repos/ssddanbrown/asserthtml/archive/v3.2.0.zip?r=0811b5c&t=zip&v=3.2.0.0"
300        );
301    }
302}