g1t/crates/scan/src/lockfiles.rs
| 1 | //! What a project depends on, read from the lockfiles its package managers |
| 2 | //! write: the exact versions that get installed, which is what an advisory |
| 3 | //! is about. |
| 4 | |
| 5 | use serde_json::Value; |
| 6 | use std::collections::BTreeSet; |
| 7 | |
| 8 | /// A package registry, named as OSV names it. |
| 9 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 10 | pub enum Ecosystem { |
| 11 | Npm, |
| 12 | Cargo, |
| 13 | Go, |
| 14 | PyPI, |
| 15 | } |
| 16 | |
| 17 | impl Ecosystem { |
| 18 | pub const ALL: [Ecosystem; 4] = [Ecosystem::Npm, Ecosystem::Cargo, Ecosystem::Go, Ecosystem::PyPI]; |
| 19 | |
| 20 | /// OSV's name, which is also what is stored. |
| 21 | pub fn osv(self) -> &'static str { |
| 22 | match self { |
| 23 | Ecosystem::Npm => "npm", |
| 24 | Ecosystem::Cargo => "crates.io", |
| 25 | Ecosystem::Go => "Go", |
| 26 | Ecosystem::PyPI => "PyPI", |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | pub fn parse(name: &str) -> Option<Ecosystem> { |
| 31 | Ecosystem::ALL.into_iter().find(|ecosystem| ecosystem.osv() == name) |
| 32 | } |
| 33 | |
| 34 | /// Package names compared as the registry compares them. |
| 35 | pub fn normalize(self, name: &str) -> String { |
| 36 | match self { |
| 37 | Ecosystem::PyPI => name.to_lowercase().replace(['_', '.'], "-"), |
| 38 | _ => name.to_owned(), |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | /// The lockfiles g1t reads, by file name. |
| 44 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 45 | pub enum Lockfile { |
| 46 | PackageLock, |
| 47 | PnpmLock, |
| 48 | YarnLock, |
| 49 | CargoLock, |
| 50 | GoMod, |
| 51 | GoSum, |
| 52 | Requirements, |
| 53 | PoetryLock, |
| 54 | } |
| 55 | |
| 56 | impl Lockfile { |
| 57 | pub const NAMES: [&'static str; 8] = [ |
| 58 | "package-lock.json", |
| 59 | "pnpm-lock.yaml", |
| 60 | "yarn.lock", |
| 61 | "Cargo.lock", |
| 62 | "go.mod", |
| 63 | "go.sum", |
| 64 | "requirements.txt", |
| 65 | "poetry.lock", |
| 66 | ]; |
| 67 | |
| 68 | pub fn for_path(path: &str) -> Option<Lockfile> { |
| 69 | Some(match path.rsplit('/').next().unwrap_or(path) { |
| 70 | "package-lock.json" => Lockfile::PackageLock, |
| 71 | "pnpm-lock.yaml" => Lockfile::PnpmLock, |
| 72 | "yarn.lock" => Lockfile::YarnLock, |
| 73 | "Cargo.lock" => Lockfile::CargoLock, |
| 74 | "go.mod" => Lockfile::GoMod, |
| 75 | "go.sum" => Lockfile::GoSum, |
| 76 | "requirements.txt" => Lockfile::Requirements, |
| 77 | "poetry.lock" => Lockfile::PoetryLock, |
| 78 | _ => return None, |
| 79 | }) |
| 80 | } |
| 81 | |
| 82 | pub fn ecosystem(self) -> Ecosystem { |
| 83 | match self { |
| 84 | Lockfile::PackageLock | Lockfile::PnpmLock | Lockfile::YarnLock => Ecosystem::Npm, |
| 85 | Lockfile::CargoLock => Ecosystem::Cargo, |
| 86 | Lockfile::GoMod | Lockfile::GoSum => Ecosystem::Go, |
| 87 | Lockfile::Requirements | Lockfile::PoetryLock => Ecosystem::PyPI, |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | pub fn parse(self, text: &str) -> Vec<Package> { |
| 92 | let found = match self { |
| 93 | Lockfile::PackageLock => package_lock(text), |
| 94 | Lockfile::PnpmLock => pnpm_lock(text), |
| 95 | Lockfile::YarnLock => yarn_lock(text), |
| 96 | Lockfile::CargoLock => toml_packages(text, true), |
| 97 | Lockfile::GoMod => go_mod(text), |
| 98 | Lockfile::GoSum => go_sum(text), |
| 99 | Lockfile::Requirements => requirements(text), |
| 100 | Lockfile::PoetryLock => toml_packages(text, false), |
| 101 | }; |
| 102 | let ecosystem = self.ecosystem(); |
| 103 | let unique: BTreeSet<(String, String)> = found |
| 104 | .into_iter() |
| 105 | .filter(|(name, version)| !name.is_empty() && version.starts_with(|c: char| c.is_ascii_digit() || c == 'v')) |
| 106 | .map(|(name, version)| (ecosystem.normalize(&name), version)) |
| 107 | .collect(); |
| 108 | unique |
| 109 | .into_iter() |
| 110 | .map(|(name, version)| Package { ecosystem, name, version }) |
| 111 | .collect() |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | /// One package at one version. |
| 116 | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] |
| 117 | pub struct Package { |
| 118 | pub ecosystem: Ecosystem, |
| 119 | pub name: String, |
| 120 | pub version: String, |
| 121 | } |
| 122 | |
| 123 | /// `package-lock.json` (and `npm-shrinkwrap.json`): lockfile v2 and v3 list |
| 124 | /// every installed path under `packages`; v1 nests `dependencies`. |
| 125 | fn package_lock(text: &str) -> Vec<(String, String)> { |
| 126 | let Ok(lock) = serde_json::from_str::<Value>(text) else { |
| 127 | return Vec::new(); |
| 128 | }; |
| 129 | let mut found = Vec::new(); |
| 130 | if let Some(packages) = lock.get("packages").and_then(Value::as_object) { |
| 131 | for (path, entry) in packages { |
| 132 | let Some(at) = path.rfind("node_modules/") else { |
| 133 | continue; // the project itself, or one of its workspaces |
| 134 | }; |
| 135 | if entry.get("link").and_then(Value::as_bool) == Some(true) { |
| 136 | continue; |
| 137 | } |
| 138 | let name = entry |
| 139 | .get("name") |
| 140 | .and_then(Value::as_str) |
| 141 | .unwrap_or(&path[at + "node_modules/".len()..]); |
| 142 | if let Some(version) = entry.get("version").and_then(Value::as_str) { |
| 143 | found.push((name.to_owned(), version.to_owned())); |
| 144 | } |
| 145 | } |
| 146 | return found; |
| 147 | } |
| 148 | fn walk(dependencies: &Value, found: &mut Vec<(String, String)>) { |
| 149 | let Some(dependencies) = dependencies.as_object() else { |
| 150 | return; |
| 151 | }; |
| 152 | for (name, entry) in dependencies { |
| 153 | if let Some(version) = entry.get("version").and_then(Value::as_str) { |
| 154 | found.push((name.clone(), version.to_owned())); |
| 155 | } |
| 156 | if let Some(nested) = entry.get("dependencies") { |
| 157 | walk(nested, found); |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | if let Some(dependencies) = lock.get("dependencies") { |
| 162 | walk(dependencies, &mut found); |
| 163 | } |
| 164 | found |
| 165 | } |
| 166 | |
| 167 | /// `name@version` or `name/version`, as pnpm writes a package's key, with |
| 168 | /// the peer-dependency suffix that follows removed. |
| 169 | fn pnpm_key(key: &str) -> Option<(String, String)> { |
| 170 | let key = key.trim().trim_end_matches(':').trim_matches(['\'', '"']); |
| 171 | let key = key.strip_prefix('/').unwrap_or(key); |
| 172 | let key = key.split('(').next().unwrap_or(key); |
| 173 | // A scoped name has one slash in it, any other none. |
| 174 | let named = |name: &str| !name.is_empty() && name.matches('/').count() == usize::from(name.starts_with('@')); |
| 175 | // `@scope/name@1.0.0`: the version follows the last `@` after the first character. |
| 176 | if let Some(at) = key.get(1..).and_then(|rest| rest.rfind('@')).map(|at| at + 1) { |
| 177 | let (name, version) = (&key[..at], &key[at + 1..]); |
| 178 | if named(name) { |
| 179 | return Some((name.to_owned(), version.split('_').next().unwrap_or_default().to_owned())); |
| 180 | } |
| 181 | } |
| 182 | // Lockfile v5: `/name/1.0.0_peer@2.0.0` or `/@scope/name/1.0.0`. |
| 183 | let key = key.split('_').next().unwrap_or(key); |
| 184 | let (name, version) = key.rsplit_once('/')?; |
| 185 | named(name).then(|| (name.to_owned(), version.to_owned())) |
| 186 | } |
| 187 | |
| 188 | /// `pnpm-lock.yaml`: each package is a two-space-indented key under the |
| 189 | /// top-level `packages:`. |
| 190 | fn pnpm_lock(text: &str) -> Vec<(String, String)> { |
| 191 | let mut found = Vec::new(); |
| 192 | let mut inside = false; |
| 193 | for line in text.lines() { |
| 194 | if !line.starts_with(' ') && !line.trim().is_empty() { |
| 195 | inside = line.trim_end() == "packages:"; |
| 196 | continue; |
| 197 | } |
| 198 | if !inside || !line.starts_with(" ") || line.starts_with(" ") || !line.trim_end().ends_with(':') { |
| 199 | continue; |
| 200 | } |
| 201 | if let Some(package) = pnpm_key(line) { |
| 202 | found.push(package); |
| 203 | } |
| 204 | } |
| 205 | found |
| 206 | } |
| 207 | |
| 208 | /// `yarn.lock`, classic and Berry: a header naming the package and the |
| 209 | /// ranges it satisfies, then an indented `version`. |
| 210 | fn yarn_lock(text: &str) -> Vec<(String, String)> { |
| 211 | let mut found = Vec::new(); |
| 212 | let mut name: Option<String> = None; |
| 213 | for line in text.lines() { |
| 214 | if line.starts_with('#') || line.trim().is_empty() { |
| 215 | continue; |
| 216 | } |
| 217 | if !line.starts_with(' ') { |
| 218 | name = None; |
| 219 | let first = line.trim_end_matches(':').split(',').next().unwrap_or_default(); |
| 220 | let spec = first.trim().trim_matches('"'); |
| 221 | if spec.contains("@workspace:") || spec.contains("@patch:") || spec.contains("@link:") { |
| 222 | continue; |
| 223 | } |
| 224 | if let Some(at) = spec[1.min(spec.len())..].find('@').map(|at| at + 1) { |
| 225 | name = Some(spec[..at].to_owned()); |
| 226 | } |
| 227 | continue; |
| 228 | } |
| 229 | let trimmed = line.trim(); |
| 230 | if let (Some(current), Some(rest)) = (&name, trimmed.strip_prefix("version")) { |
| 231 | let version = rest.trim_start_matches(':').trim().trim_matches('"'); |
| 232 | found.push((current.clone(), version.to_owned())); |
| 233 | name = None; |
| 234 | } |
| 235 | } |
| 236 | found |
| 237 | } |
| 238 | |
| 239 | /// `Cargo.lock` and `poetry.lock`: `[[package]]` tables with `name` and |
| 240 | /// `version`. For Cargo only crates from a registry count; the project's |
| 241 | /// own crates have no `source`. |
| 242 | fn toml_packages(text: &str, registry_only: bool) -> Vec<(String, String)> { |
| 243 | let mut found = Vec::new(); |
| 244 | let mut current: Option<(Option<String>, Option<String>, bool)> = None; |
| 245 | let mut finish = |current: &mut Option<(Option<String>, Option<String>, bool)>| { |
| 246 | if let Some((Some(name), Some(version), from_registry)) = current.take() |
| 247 | && (!registry_only || from_registry) |
| 248 | { |
| 249 | found.push((name, version)); |
| 250 | } |
| 251 | }; |
| 252 | for line in text.lines() { |
| 253 | let line = line.trim(); |
| 254 | if line.starts_with('[') { |
| 255 | finish(&mut current); |
| 256 | if line == "[[package]]" { |
| 257 | current = Some((None, None, false)); |
| 258 | } |
| 259 | continue; |
| 260 | } |
| 261 | let Some(entry) = current.as_mut() else { |
| 262 | continue; |
| 263 | }; |
| 264 | let Some((key, value)) = line.split_once('=') else { |
| 265 | continue; |
| 266 | }; |
| 267 | let value = value.trim().trim_matches('"').to_owned(); |
| 268 | match key.trim() { |
| 269 | "name" => entry.0 = Some(value), |
| 270 | "version" => entry.1 = Some(value), |
| 271 | "source" => entry.2 = value.starts_with("registry+") || value.starts_with("sparse+"), |
| 272 | _ => {} |
| 273 | } |
| 274 | } |
| 275 | finish(&mut current); |
| 276 | found |
| 277 | } |
| 278 | |
| 279 | /// `go.mod`'s `require` lines, in a block or one at a time: the versions |
| 280 | /// the build actually uses. |
| 281 | fn go_mod(text: &str) -> Vec<(String, String)> { |
| 282 | let mut found = Vec::new(); |
| 283 | let mut block = false; |
| 284 | for line in text.lines() { |
| 285 | let line = line.split("//").next().unwrap_or_default().trim(); |
| 286 | if block { |
| 287 | if line == ")" { |
| 288 | block = false; |
| 289 | continue; |
| 290 | } |
| 291 | } else if line.starts_with("require (") || line == "require(" { |
| 292 | block = true; |
| 293 | continue; |
| 294 | } |
| 295 | let line = if block { line } else if let Some(rest) = line.strip_prefix("require ") { rest.trim() } else { continue }; |
| 296 | let mut words = line.split_whitespace(); |
| 297 | if let (Some(module), Some(version)) = (words.next(), words.next()) { |
| 298 | found.push((module.to_owned(), version.to_owned())); |
| 299 | } |
| 300 | } |
| 301 | found |
| 302 | } |
| 303 | |
| 304 | /// `go.sum` lists every version a build ever considered; the highest of |
| 305 | /// each module is the one in use. |
| 306 | fn go_sum(text: &str) -> Vec<(String, String)> { |
| 307 | let mut highest: std::collections::BTreeMap<String, String> = Default::default(); |
| 308 | for line in text.lines() { |
| 309 | let mut words = line.split_whitespace(); |
| 310 | let (Some(module), Some(version)) = (words.next(), words.next()) else { |
| 311 | continue; |
| 312 | }; |
| 313 | let version = version.trim_end_matches("/go.mod"); |
| 314 | let keep = highest |
| 315 | .get(module) |
| 316 | .is_none_or(|current| crate::version::compare(version, current).is_gt()); |
| 317 | if keep { |
| 318 | highest.insert(module.to_owned(), version.to_owned()); |
| 319 | } |
| 320 | } |
| 321 | highest.into_iter().collect() |
| 322 | } |
| 323 | |
| 324 | /// `requirements.txt`: only pinned lines, `name==1.2.3`, say what is |
| 325 | /// installed. |
| 326 | fn requirements(text: &str) -> Vec<(String, String)> { |
| 327 | let mut found = Vec::new(); |
| 328 | for line in text.lines() { |
| 329 | let line = line.split('#').next().unwrap_or_default(); |
| 330 | let line = line.split(';').next().unwrap_or_default().trim(); |
| 331 | if line.starts_with('-') || line.contains("://") { |
| 332 | continue; |
| 333 | } |
| 334 | let Some((name, version)) = line.split_once("===").or_else(|| line.split_once("==")) else { |
| 335 | continue; |
| 336 | }; |
| 337 | let name = name.split('[').next().unwrap_or_default().trim(); |
| 338 | let version = version.split([',', ' ']).next().unwrap_or_default().trim(); |
| 339 | if !name.is_empty() && !version.contains('*') { |
| 340 | found.push((name.to_owned(), version.to_owned())); |
| 341 | } |
| 342 | } |
| 343 | found |
| 344 | } |
| 345 | |
| 346 | /// A shell command that fails while `lockfile` still resolves `name` at |
| 347 | /// `version`: an acceptance check for an upgrade, which passes only once |
| 348 | /// the vulnerable version is gone from the lockfile. |
| 349 | pub fn still_locked_check(lockfile: Lockfile, path: &str, name: &str, version: &str) -> String { |
| 350 | let quote = |text: &str| format!("'{}'", text.replace('\'', "'\\''")); |
| 351 | let escape = |text: &str| { |
| 352 | text.chars() |
| 353 | .flat_map(|c| if ".[]^$*+?(){}|\\".contains(c) { vec!['\\', c] } else { vec![c] }) |
| 354 | .collect::<String>() |
| 355 | }; |
| 356 | let file = quote(path); |
| 357 | match lockfile { |
| 358 | Lockfile::PackageLock => format!( |
| 359 | "node -e {} {file}", |
| 360 | quote(&format!( |
| 361 | "const l=require(require('path').resolve(process.argv[1]));const hit=Object.entries(l.packages||{{}}).some(([k,p])=>k.endsWith('node_modules/{name}')&&p.version==='{version}');process.exit(hit?1:0)" |
| 362 | )) |
| 363 | ), |
| 364 | Lockfile::PnpmLock => format!( |
| 365 | "! grep -Eq {} {file}", |
| 366 | quote(&format!("^ +'?/?{}[@/]{}[:(_']", escape(name), escape(version))) |
| 367 | ), |
| 368 | Lockfile::YarnLock => format!( |
| 369 | "! grep -A3 -E {} {file} | grep -Eq {}", |
| 370 | quote(&format!("^\"?{}@", escape(name))), |
| 371 | quote(&format!("^ +version:? \"?{}\"?$", escape(version))) |
| 372 | ), |
| 373 | Lockfile::CargoLock | Lockfile::PoetryLock => format!( |
| 374 | "! grep -A1 -x {} {file} | grep -qx {}", |
| 375 | quote(&format!("name = \"{name}\"")), |
| 376 | quote(&format!("version = \"{version}\"")) |
| 377 | ), |
| 378 | Lockfile::GoMod | Lockfile::GoSum => format!( |
| 379 | "! grep -Eq {} {file}", |
| 380 | quote(&format!("(^|[[:space:]]){} {}([[:space:]]|/|$)", escape(name), escape(version))) |
| 381 | ), |
| 382 | Lockfile::Requirements => format!( |
| 383 | "! grep -Eiq {} {file}", |
| 384 | quote(&format!("^{}(\\[.*\\])? *===? *{}([^0-9.]|$)", escape(name).replace('-', "[-_.]"), escape(version))) |
| 385 | ), |
| 386 | } |
| 387 | } |
| 388 | |
| 389 | /// The command that runs a project's tests, by what its lockfile says it is. |
| 390 | pub fn test_command(lockfile: Lockfile, directory: &str) -> Option<String> { |
| 391 | let cd = if directory.is_empty() { String::new() } else { format!("cd '{directory}' && ") }; |
| 392 | Some(match lockfile { |
| 393 | Lockfile::PackageLock => format!("{cd}npm ci && npm test --if-present"), |
| 394 | Lockfile::PnpmLock => format!("{cd}pnpm install --frozen-lockfile && pnpm test --if-present"), |
| 395 | Lockfile::YarnLock => format!("{cd}yarn install --immutable || yarn install --frozen-lockfile; yarn test"), |
| 396 | Lockfile::CargoLock => format!("{cd}cargo test --locked"), |
| 397 | Lockfile::GoMod | Lockfile::GoSum => format!("{cd}go test ./..."), |
| 398 | Lockfile::Requirements | Lockfile::PoetryLock => return None, |
| 399 | }) |
| 400 | } |
| 401 | |
| 402 | #[cfg(test)] |
| 403 | mod tests { |
| 404 | use super::*; |
| 405 | |
| 406 | fn names(lockfile: Lockfile, text: &str) -> Vec<String> { |
| 407 | lockfile |
| 408 | .parse(text) |
| 409 | .into_iter() |
| 410 | .map(|package| format!("{}@{}", package.name, package.version)) |
| 411 | .collect() |
| 412 | } |
| 413 | |
| 414 | #[test] |
| 415 | fn package_lock_v3_and_v1() { |
| 416 | let v3 = r#"{"lockfileVersion":3,"packages":{ |
| 417 | "":{"name":"app","version":"1.0.0"}, |
| 418 | "node_modules/lodash":{"version":"4.17.20"}, |
| 419 | "node_modules/@babel/core":{"version":"7.0.0"}, |
| 420 | "node_modules/a/node_modules/lodash":{"version":"4.17.4"}, |
| 421 | "packages/web":{"version":"0.1.0"}, |
| 422 | "node_modules/web":{"resolved":"packages/web","link":true} |
| 423 | }}"#; |
| 424 | assert_eq!(names(Lockfile::PackageLock, v3), ["@babel/core@7.0.0", "lodash@4.17.20", "lodash@4.17.4"]); |
| 425 | let v1 = r#"{"lockfileVersion":1,"dependencies":{"minimist":{"version":"0.0.8","dependencies":{"x":{"version":"1.0.0"}}}}}"#; |
| 426 | assert_eq!(names(Lockfile::PackageLock, v1), ["minimist@0.0.8", "x@1.0.0"]); |
| 427 | } |
| 428 | |
| 429 | #[test] |
| 430 | fn pnpm_lock_v5_v6_and_v9() { |
| 431 | let v5 = "lockfileVersion: 5.4\npackages:\n /lodash/4.17.20:\n resolution: {integrity: x}\n /@babel/core/7.0.0_react@18.0.0:\n dev: true\n"; |
| 432 | assert_eq!(names(Lockfile::PnpmLock, v5), ["@babel/core@7.0.0", "lodash@4.17.20"]); |
| 433 | let v6 = "lockfileVersion: '6.0'\npackages:\n /lodash@4.17.20:\n resolution: {}\n /@types/node@20.1.0(typescript@5.0.0):\n dev: true\n"; |
| 434 | assert_eq!(names(Lockfile::PnpmLock, v6), ["@types/node@20.1.0", "lodash@4.17.20"]); |
| 435 | let v9 = "lockfileVersion: '9.0'\nimporters:\n .:\n dependencies: {}\npackages:\n lodash@4.17.20:\n resolution: {}\n '@babel/core@7.24.0':\n resolution: {}\nsnapshots:\n lodash@4.17.20: {}\n"; |
| 436 | assert_eq!(names(Lockfile::PnpmLock, v9), ["@babel/core@7.24.0", "lodash@4.17.20"]); |
| 437 | } |
| 438 | |
| 439 | #[test] |
| 440 | fn yarn_classic_and_berry() { |
| 441 | let classic = "# yarn lockfile v1\n\nlodash@^4.17.0, lodash@^4.17.15:\n version \"4.17.20\"\n resolved \"x\"\n\n\"@babel/core@^7.0.0\":\n version \"7.1.0\"\n"; |
| 442 | assert_eq!(names(Lockfile::YarnLock, classic), ["@babel/core@7.1.0", "lodash@4.17.20"]); |
| 443 | let berry = "__metadata:\n version: 6\n\n\"lodash@npm:^4.17.0\":\n version: 4.17.20\n resolution: \"lodash@npm:4.17.20\"\n\n\"app@workspace:.\":\n version: 0.0.0-use.local\n"; |
| 444 | assert_eq!(names(Lockfile::YarnLock, berry), ["lodash@4.17.20"]); |
| 445 | } |
| 446 | |
| 447 | #[test] |
| 448 | fn cargo_lock_counts_registry_crates_only() { |
| 449 | let lock = "version = 3\n\n[[package]]\nname = \"app\"\nversion = \"0.1.0\"\n\n[[package]]\nname = \"time\"\nversion = \"0.1.43\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"x\"\n\n[[package]]\nname = \"smallvec\"\nversion = \"1.6.0\"\nsource = \"sparse+https://index.crates.io/\"\n"; |
| 450 | assert_eq!(names(Lockfile::CargoLock, lock), ["smallvec@1.6.0", "time@0.1.43"]); |
| 451 | } |
| 452 | |
| 453 | #[test] |
| 454 | fn go_mod_and_go_sum() { |
| 455 | let module = "module example.com/app\n\ngo 1.22\n\nrequire golang.org/x/text v0.3.0\n\nrequire (\n\tgithub.com/gin-gonic/gin v1.6.0 // indirect\n\tgolang.org/x/net v0.7.0\n)\n"; |
| 456 | assert_eq!( |
| 457 | names(Lockfile::GoMod, module), |
| 458 | ["github.com/gin-gonic/gin@v1.6.0", "golang.org/x/net@v0.7.0", "golang.org/x/text@v0.3.0"] |
| 459 | ); |
| 460 | let sum = "golang.org/x/text v0.3.0 h1:x=\ngolang.org/x/text v0.3.0/go.mod h1:y=\ngolang.org/x/text v0.3.8 h1:z=\n"; |
| 461 | assert_eq!(names(Lockfile::GoSum, sum), ["golang.org/x/text@v0.3.8"]); |
| 462 | } |
| 463 | |
| 464 | #[test] |
| 465 | fn requirements_and_poetry() { |
| 466 | let requirements = "# web\nDjango==3.2.0\nrequests[security]==2.19.1 ; python_version >= '3'\nflask>=2.0\n-r other.txt\nPyYAML===5.3\n"; |
| 467 | assert_eq!(names(Lockfile::Requirements, requirements), ["django@3.2.0", "pyyaml@5.3", "requests@2.19.1"]); |
| 468 | let poetry = "[[package]]\nname = \"Jinja2\"\nversion = \"2.10\"\ndescription = \"x\"\n\n[package.dependencies]\nMarkupSafe = \">=0.23\"\n\n[[package]]\nname = \"urllib3\"\nversion = \"1.24.1\"\n\n[metadata]\nlock-version = \"2.0\"\n"; |
| 469 | assert_eq!(names(Lockfile::PoetryLock, poetry), ["jinja2@2.10", "urllib3@1.24.1"]); |
| 470 | } |
| 471 | |
| 472 | #[test] |
| 473 | fn the_check_names_the_file_and_the_version() { |
| 474 | let check = still_locked_check(Lockfile::CargoLock, "Cargo.lock", "time", "0.1.43"); |
| 475 | assert_eq!(check, "! grep -A1 -x 'name = \"time\"' 'Cargo.lock' | grep -qx 'version = \"0.1.43\"'"); |
| 476 | let npm = still_locked_check(Lockfile::PackageLock, "web/package-lock.json", "lodash", "4.17.20"); |
| 477 | assert!(npm.starts_with("node -e '") && npm.ends_with(" 'web/package-lock.json'")); |
| 478 | assert!(npm.contains("node_modules/lodash") && npm.contains("4.17.20")); |
| 479 | let go = still_locked_check(Lockfile::GoMod, "go.mod", "golang.org/x/net", "v0.7.0"); |
| 480 | assert!(go.contains("golang\\.org/x/net v0\\.7\\.0")); |
| 481 | assert_eq!(test_command(Lockfile::CargoLock, ""), Some("cargo test --locked".to_owned())); |
| 482 | assert_eq!(test_command(Lockfile::PackageLock, "web").as_deref(), Some("cd 'web' && npm ci && npm test --if-present")); |
| 483 | } |
| 484 | } |