nixpkgs mirror (for testing) github.com/NixOS/nixpkgs
nix
at devShellTools-shell 370 lines 11 kB view raw
1use anyhow::{anyhow, bail, Context}; 2use rayon::slice::ParallelSliceMut; 3use serde::{ 4 de::{self, Visitor}, 5 Deserialize, Deserializer, 6}; 7use std::{ 8 cmp::Ordering, 9 collections::{HashMap, HashSet}, 10 fmt, 11}; 12use url::Url; 13 14pub(super) fn packages(content: &str) -> anyhow::Result<Vec<Package>> { 15 let lockfile: Lockfile = serde_json::from_str(content)?; 16 17 let mut packages = match lockfile.version { 18 1 => { 19 let initial_url = get_initial_url()?; 20 21 to_new_packages(lockfile.dependencies.unwrap_or_default(), &initial_url)? 22 } 23 2 | 3 => lockfile 24 .packages 25 .unwrap_or_default() 26 .into_iter() 27 .filter(|(n, p)| !n.is_empty() && matches!(p.resolved, Some(UrlOrString::Url(_)))) 28 .map(|(n, p)| Package { name: Some(n), ..p }) 29 .collect(), 30 _ => bail!( 31 "We don't support lockfile version {}, please file an issue.", 32 lockfile.version 33 ), 34 }; 35 36 packages.par_sort_by(|x, y| { 37 x.resolved 38 .partial_cmp(&y.resolved) 39 .expect("resolved should be comparable") 40 .then( 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) 43 y.integrity 44 .partial_cmp(&x.integrity) 45 .expect("integrity should be comparable"), 46 ) 47 }); 48 49 packages.dedup_by(|x, y| x.resolved == y.resolved); 50 51 Ok(packages) 52} 53 54#[derive(Deserialize)] 55struct Lockfile { 56 #[serde(rename = "lockfileVersion")] 57 version: u8, 58 dependencies: Option<HashMap<String, OldPackage>>, 59 packages: Option<HashMap<String, Package>>, 60} 61 62#[derive(Deserialize)] 63struct OldPackage { 64 version: UrlOrString, 65 #[serde(default)] 66 bundled: bool, 67 resolved: Option<UrlOrString>, 68 integrity: Option<HashCollection>, 69 dependencies: Option<HashMap<String, OldPackage>>, 70} 71 72#[derive(Debug, Deserialize, PartialEq, Eq)] 73pub(super) struct Package { 74 #[serde(default)] 75 pub(super) name: Option<String>, 76 pub(super) resolved: Option<UrlOrString>, 77 pub(super) integrity: Option<HashCollection>, 78} 79 80#[derive(Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] 81#[serde(untagged)] 82pub(super) enum UrlOrString { 83 Url(Url), 84 String(String), 85} 86 87impl fmt::Display for UrlOrString { 88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 89 match self { 90 UrlOrString::Url(url) => url.fmt(f), 91 UrlOrString::String(string) => string.fmt(f), 92 } 93 } 94} 95 96#[derive(Debug, PartialEq, Eq)] 97pub struct HashCollection(HashSet<Hash>); 98 99impl HashCollection { 100 pub fn from_str(s: impl AsRef<str>) -> anyhow::Result<HashCollection> { 101 let hashes = s 102 .as_ref() 103 .split_ascii_whitespace() 104 .map(Hash::new) 105 .collect::<anyhow::Result<_>>()?; 106 107 Ok(HashCollection(hashes)) 108 } 109 110 pub fn into_best(self) -> Option<Hash> { 111 self.0.into_iter().max() 112 } 113} 114 115impl 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()?; 119 120 lhs.partial_cmp(rhs) 121 } 122} 123 124impl<'de> Deserialize<'de> for HashCollection { 125 fn deserialize<D>(deserializer: D) -> Result<HashCollection, D::Error> 126 where 127 D: Deserializer<'de>, 128 { 129 deserializer.deserialize_string(HashCollectionVisitor) 130 } 131} 132 133struct HashCollectionVisitor; 134 135impl<'de> Visitor<'de> for HashCollectionVisitor { 136 type Value = HashCollection; 137 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)") 140 } 141 142 fn visit_str<E>(self, value: &str) -> Result<HashCollection, E> 143 where 144 E: de::Error, 145 { 146 HashCollection::from_str(value).map_err(E::custom) 147 } 148} 149 150#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Hash)] 151pub struct Hash(String); 152 153// Hash algorithms, in ascending preference. 154const ALGOS: &[&str] = &["sha1", "sha512"]; 155 156impl Hash { 157 fn new(s: impl AsRef<str>) -> anyhow::Result<Hash> { 158 let algo = s 159 .as_ref() 160 .split_once('-') 161 .ok_or_else(|| anyhow!("expected SRI hash, got {:?}", s.as_ref()))? 162 .0; 163 164 if ALGOS.iter().any(|&a| algo == a) { 165 Ok(Hash(s.as_ref().to_string())) 166 } else { 167 Err(anyhow!("unknown hash algorithm {algo:?}")) 168 } 169 } 170 171 pub fn as_str(&self) -> &str { 172 &self.0 173 } 174} 175 176impl fmt::Display for Hash { 177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 178 self.as_str().fmt(f) 179 } 180} 181 182#[allow(clippy::non_canonical_partial_ord_impl)] 183impl 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; 187 188 ALGOS 189 .iter() 190 .position(|&s| lhs == s)? 191 .partial_cmp(&ALGOS.iter().position(|&s| rhs == s)?) 192 } 193} 194 195impl Ord for Hash { 196 fn cmp(&self, other: &Hash) -> Ordering { 197 self.partial_cmp(other).unwrap() 198 } 199} 200 201#[allow(clippy::case_sensitive_file_extension_comparisons)] 202fn to_new_packages( 203 old_packages: HashMap<String, OldPackage>, 204 initial_url: &Url, 205) -> anyhow::Result<Vec<Package>> { 206 let mut new = Vec::new(); 207 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. 212 if package.bundled { 213 continue; 214 } 215 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()); 220 } 221 } else { 222 for (scheme, host) in [ 223 ("github", "github.com"), 224 ("bitbucket", "bitbucket.org"), 225 ("gitlab", "gitlab.com"), 226 ] { 227 if v.scheme() == scheme { 228 package.version = { 229 let mut new_url = initial_url.clone(); 230 231 new_url.set_host(Some(host))?; 232 233 if v.path().ends_with(".git") { 234 new_url.set_path(v.path()); 235 } else { 236 new_url.set_path(&format!("{}.git", v.path())); 237 } 238 239 new_url.set_fragment(v.fragment()); 240 241 UrlOrString::Url(new_url) 242 }; 243 244 break; 245 } 246 } 247 } 248 } 249 250 new.push(Package { 251 name: Some(name), 252 resolved: if matches!(package.version, UrlOrString::Url(_)) { 253 Some(package.version) 254 } else { 255 package.resolved 256 }, 257 integrity: package.integrity, 258 }); 259 260 if let Some(dependencies) = package.dependencies { 261 new.append(&mut to_new_packages(dependencies, initial_url)?); 262 } 263 } 264 265 Ok(new) 266} 267 268fn get_initial_url() -> anyhow::Result<Url> { 269 Url::parse("git+ssh://git@a.b").context("initial url should be valid") 270} 271 272#[cfg(test)] 273mod tests { 274 use super::{ 275 get_initial_url, packages, to_new_packages, Hash, HashCollection, OldPackage, Package, 276 UrlOrString, 277 }; 278 use std::{ 279 cmp::Ordering, 280 collections::{HashMap, HashSet}, 281 }; 282 use url::Url; 283 284 #[test] 285 fn git_shorthand_v1() -> anyhow::Result<()> { 286 let old = { 287 let mut o = HashMap::new(); 288 o.insert( 289 String::from("sqlite3"), 290 OldPackage { 291 version: UrlOrString::Url( 292 Url::parse( 293 "github:mapbox/node-sqlite3#593c9d498be2510d286349134537e3bf89401c4a", 294 ) 295 .unwrap(), 296 ), 297 bundled: false, 298 resolved: None, 299 integrity: None, 300 dependencies: None, 301 }, 302 ); 303 o 304 }; 305 306 let initial_url = get_initial_url()?; 307 308 let new = to_new_packages(old, &initial_url)?; 309 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())), 314 integrity: None 315 }); 316 317 Ok(()) 318 } 319 320 #[test] 321 fn hash_preference() { 322 assert_eq!( 323 Hash(String::from("sha1-foo")).partial_cmp(&Hash(String::from("sha512-foo"))), 324 Some(Ordering::Less) 325 ); 326 327 assert_eq!( 328 HashCollection({ 329 let mut set = HashSet::new(); 330 set.insert(Hash(String::from("sha512-foo"))); 331 set.insert(Hash(String::from("sha1-bar"))); 332 set 333 }) 334 .into_best(), 335 Some(Hash(String::from("sha512-foo"))) 336 ); 337 } 338 339 #[test] 340 fn parse_lockfile_correctly() { 341 let packages = packages( 342 r#"{ 343 "name": "node-ddr", 344 "version": "1.0.0", 345 "lockfileVersion": 1, 346 "requires": true, 347 "dependencies": { 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==", 352 "requires": { 353 "emoji-regex": "^8.0.0", 354 "is-fullwidth-code-point": "^3.0.0", 355 "strip-ansi": "^6.0.1" 356 } 357 } 358 } 359 }"#).unwrap(); 360 361 assert_eq!(packages.len(), 1); 362 assert_eq!( 363 packages[0].resolved, 364 Some(UrlOrString::Url( 365 Url::parse("https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz") 366 .unwrap() 367 )) 368 ); 369 } 370}