1 use anyhow::{anyhow, bail, Context};
2 use rayon::slice::ParallelSliceMut;
5 Deserialize, Deserializer,
9 collections::{HashMap, HashSet},
14 pub(super) fn packages(content: &str) -> anyhow::Result<Vec<Package>> {
15 let lockfile: Lockfile = serde_json::from_str(content)?;
17 let mut packages = match lockfile.version {
19 let initial_url = get_initial_url()?;
21 to_new_packages(lockfile.dependencies.unwrap_or_default(), &initial_url)?
27 .filter(|(n, p)| !n.is_empty() && matches!(p.resolved, Some(UrlOrString::Url(_))))
28 .map(|(n, p)| Package { name: Some(n), ..p })
31 "We don't support lockfile version {}, please file an issue.",
36 packages.par_sort_by(|x, y| {
38 .partial_cmp(&y.resolved)
39 .expect("resolved should be comparable")
41 // v1 lockfiles can contain multiple references to the same version of a package, with
42 // different integrity values (e.g. a SHA-1 and a SHA-512 in one, but just a SHA-512 in another)
44 .partial_cmp(&x.integrity)
45 .expect("integrity should be comparable"),
49 packages.dedup_by(|x, y| x.resolved == y.resolved);
54 #[derive(Deserialize)]
56 #[serde(rename = "lockfileVersion")]
58 dependencies: Option<HashMap<String, OldPackage>>,
59 packages: Option<HashMap<String, Package>>,
62 #[derive(Deserialize)]
67 resolved: Option<UrlOrString>,
68 integrity: Option<HashCollection>,
69 dependencies: Option<HashMap<String, OldPackage>>,
72 #[derive(Debug, Deserialize, PartialEq, Eq)]
73 pub(super) struct Package {
75 pub(super) name: Option<String>,
76 pub(super) resolved: Option<UrlOrString>,
77 pub(super) integrity: Option<HashCollection>,
80 #[derive(Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
82 pub(super) enum UrlOrString {
87 impl fmt::Display for UrlOrString {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 UrlOrString::Url(url) => url.fmt(f),
91 UrlOrString::String(string) => string.fmt(f),
96 #[derive(Debug, PartialEq, Eq)]
97 pub struct HashCollection(HashSet<Hash>);
100 pub fn from_str(s: impl AsRef<str>) -> anyhow::Result<HashCollection> {
103 .split_ascii_whitespace()
105 .collect::<anyhow::Result<_>>()?;
107 Ok(HashCollection(hashes))
110 pub fn into_best(self) -> Option<Hash> {
111 self.0.into_iter().max()
115 impl PartialOrd for HashCollection {
116 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
117 let lhs = self.0.iter().max()?;
118 let rhs = other.0.iter().max()?;
124 impl<'de> Deserialize<'de> for HashCollection {
125 fn deserialize<D>(deserializer: D) -> Result<HashCollection, D::Error>
127 D: Deserializer<'de>,
129 deserializer.deserialize_string(HashCollectionVisitor)
133 struct HashCollectionVisitor;
135 impl<'de> Visitor<'de> for HashCollectionVisitor {
136 type Value = HashCollection;
138 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
139 formatter.write_str("a single SRI hash or a collection of them (separated by spaces)")
142 fn visit_str<E>(self, value: &str) -> Result<HashCollection, E>
146 HashCollection::from_str(value).map_err(E::custom)
150 #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Hash)]
151 pub struct Hash(String);
153 // Hash algorithms, in ascending preference.
154 const ALGOS: &[&str] = &["sha1", "sha512"];
157 fn new(s: impl AsRef<str>) -> anyhow::Result<Hash> {
161 .ok_or_else(|| anyhow!("expected SRI hash, got {:?}", s.as_ref()))?
164 if ALGOS.iter().any(|&a| algo == a) {
165 Ok(Hash(s.as_ref().to_string()))
167 Err(anyhow!("unknown hash algorithm {algo:?}"))
171 pub fn as_str(&self) -> &str {
176 impl fmt::Display for Hash {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182 #[allow(clippy::non_canonical_partial_ord_impl)]
183 impl PartialOrd for Hash {
184 fn partial_cmp(&self, other: &Hash) -> Option<Ordering> {
185 let lhs = self.0.split_once('-')?.0;
186 let rhs = other.0.split_once('-')?.0;
190 .position(|&s| lhs == s)?
191 .partial_cmp(&ALGOS.iter().position(|&s| rhs == s)?)
196 fn cmp(&self, other: &Hash) -> Ordering {
197 self.partial_cmp(other).unwrap()
201 #[allow(clippy::case_sensitive_file_extension_comparisons)]
203 old_packages: HashMap<String, OldPackage>,
205 ) -> anyhow::Result<Vec<Package>> {
206 let mut new = Vec::new();
208 for (name, mut package) in old_packages {
209 // In some cases, a bundled dependency happens to have the same version as a non-bundled one, causing
210 // the bundled one without a URL to override the entry for the non-bundled instance, which prevents the
211 // dependency from being downloaded.
216 if let UrlOrString::Url(v) = &package.version {
217 if v.scheme() == "npm" {
218 if let Some(UrlOrString::Url(ref url)) = &package.resolved {
219 package.version = UrlOrString::Url(url.clone());
222 for (scheme, host) in [
223 ("github", "github.com"),
224 ("bitbucket", "bitbucket.org"),
225 ("gitlab", "gitlab.com"),
227 if v.scheme() == scheme {
229 let mut new_url = initial_url.clone();
231 new_url.set_host(Some(host))?;
233 if v.path().ends_with(".git") {
234 new_url.set_path(v.path());
236 new_url.set_path(&format!("{}.git", v.path()));
239 new_url.set_fragment(v.fragment());
241 UrlOrString::Url(new_url)
252 resolved: if matches!(package.version, UrlOrString::Url(_)) {
253 Some(package.version)
257 integrity: package.integrity,
260 if let Some(dependencies) = package.dependencies {
261 new.append(&mut to_new_packages(dependencies, initial_url)?);
268 fn get_initial_url() -> anyhow::Result<Url> {
269 Url::parse("git+ssh://git@a.b").context("initial url should be valid")
275 get_initial_url, packages, to_new_packages, Hash, HashCollection, OldPackage, Package,
280 collections::{HashMap, HashSet},
285 fn git_shorthand_v1() -> anyhow::Result<()> {
287 let mut o = HashMap::new();
289 String::from("sqlite3"),
291 version: UrlOrString::Url(
293 "github:mapbox/node-sqlite3#593c9d498be2510d286349134537e3bf89401c4a",
306 let initial_url = get_initial_url()?;
308 let new = to_new_packages(old, &initial_url)?;
310 assert_eq!(new.len(), 1, "new packages map should contain 1 value");
311 assert_eq!(new[0], Package {
312 name: Some(String::from("sqlite3")),
313 resolved: Some(UrlOrString::Url(Url::parse("git+ssh://git@github.com/mapbox/node-sqlite3.git#593c9d498be2510d286349134537e3bf89401c4a").unwrap())),
321 fn hash_preference() {
323 Hash(String::from("sha1-foo")).partial_cmp(&Hash(String::from("sha512-foo"))),
329 let mut set = HashSet::new();
330 set.insert(Hash(String::from("sha512-foo")));
331 set.insert(Hash(String::from("sha1-bar")));
335 Some(Hash(String::from("sha512-foo")))
340 fn parse_lockfile_correctly() {
341 let packages = packages(
345 "lockfileVersion": 1,
348 "string-width-cjs": {
349 "version": "npm:string-width@4.2.3",
350 "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
351 "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
353 "emoji-regex": "^8.0.0",
354 "is-fullwidth-code-point": "^3.0.0",
355 "strip-ansi": "^6.0.1"
361 assert_eq!(packages.len(), 1);
363 packages[0].resolved,
364 Some(UrlOrString::Url(
365 Url::parse("https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz")