| 1 | //! Makes a security update: raises one package to a fixed version in the |
| 2 | //! lockfiles that resolve a vulnerable one, with the ecosystem's own tool, |
| 3 | //! commits that as g1t and pushes it to a branch of its own. The push is |
| 4 | //! what tells the security service to open the pull request; this opens |
| 5 | //! nothing itself. |
| 6 | //! |
| 7 | //! What each lockfile is updated with (the lockfiles `g1t_scan::lockfiles` |
| 8 | //! reads): |
| 9 | //! |
| 10 | //! | Lockfile | A direct dependency | Any other | |
| 11 | //! | --- | --- | --- | |
| 12 | //! | `package-lock.json` | `npm install --package-lock-only <pkg>@<range>` | `npm update --package-lock-only <pkg>`, then an `overrides` entry | |
| 13 | //! | `pnpm-lock.yaml` | `pnpm update <pkg>@<range> --lockfile-only` | `pnpm update <pkg> --depth Infinity --lockfile-only`, then `pnpm.overrides` | |
| 14 | //! | `yarn.lock` (2 and later) | `yarn up <pkg>@<range> --mode=update-lockfile` | `yarn up --recursive <pkg>`, then `resolutions` | |
| 15 | //! | `yarn.lock` (1) | `yarn upgrade <pkg>@<range>` | `resolutions` | |
| 16 | //! | `Cargo.lock` | `cargo update -p <pkg>@<old> --precise <version>`, else `cargo update -p <pkg>@<old>` | the same | |
| 17 | //! | `go.mod`, `go.sum` | `go get <module>@v<version>`, then `go mod tidy` | the same | |
| 18 | //! | `poetry.lock` | `poetry add <pkg>@^<version> --lock` | `poetry update --lock <pkg>` | |
| 19 | //! | `requirements.txt` | its `==` pins rewritten | the same | |
| 20 | //! |
| 21 | //! A direct dependency keeps its range's style (`^`, `~` or exact). No |
| 22 | //! install script runs. pnpm and yarn run through corepack, so the |
| 23 | //! version a project names in `packageManager` is the one used. Poetry is |
| 24 | //! installed into a virtual environment if the sandbox has none. |
| 25 | //! |
| 26 | //! Afterwards every lockfile is read again, and the update counts only if |
| 27 | //! none of them resolves the package below the version any more. When the |
| 28 | //! tool cannot get there (another package holds it back), the job fails |
| 29 | //! saying it needs code changes, with the tool's last lines. |
| 30 | //! |
| 31 | //! Configuration: |
| 32 | //! |
| 33 | //! - `GIT_REMOTE`, `GIT_BRANCH_BASE`: the repository and its default branch. |
| 34 | //! - `GIT_BRANCH`: the branch to push, under `g1t/security/`. |
| 35 | //! - `BUMP_ECOSYSTEM` (OSV's name: `npm`, `crates.io`, `Go`, `PyPI`), |
| 36 | //! `BUMP_PACKAGE`, `BUMP_VERSION`: what to raise, to what. |
| 37 | //! - `BUMP_LOCKFILES`: the lockfiles' paths from the root, one per line or |
| 38 | //! as a JSON array. |
| 39 | //! - `COMMIT_MESSAGE`: the commit's message. |
| 40 | //! - `G1T_USER`, `G1T_TOKEN`: to clone and push; passed per command, never |
| 41 | //! written to the clone's config or remote. |
| 42 | //! |
| 43 | //! It prints one line of JSON on stdout saying what happened, and exits 0 |
| 44 | //! once the branch is pushed; otherwise non-zero, with why on stderr: |
| 45 | //! `NEEDS_CHANGES_EXIT` when the update needs code changes, |
| 46 | //! `UNSUPPORTED_EXIT` for a lockfile or tool it cannot update. |
| 47 | |
| 48 | use std::collections::BTreeSet; |
| 49 | use std::path::Path; |
| 50 | use std::process::Command; |
| 51 | |
| 52 | use anyhow::{Context, Result, anyhow, bail}; |
| 53 | use g1t_scan::lockfiles::{Ecosystem, Lockfile}; |
| 54 | use g1t_scan::version; |
| 55 | use serde_json::{Value, json}; |
| 56 | |
| 57 | use crate::{WORKDIR, auth_option, env, git}; |
| 58 | |
| 59 | /// g1t's own name and address on the commits it makes: |
| 60 | /// `g1t_contracts::system::{USERNAME, EMAIL}`. |
| 61 | const AUTHOR_NAME: &str = "g1t"; |
| 62 | const AUTHOR_EMAIL: &str = "g1t@users.noreply.g1t.sh"; |
| 63 | /// Every security update's branch starts with this: |
| 64 | /// `g1t_contracts::security::UPDATE_BRANCH_PREFIX`. |
| 65 | const BRANCH_PREFIX: &str = "g1t/security/"; |
| 66 | |
| 67 | /// The exit code when the update needs code changes, not just a lockfile. |
| 68 | pub const NEEDS_CHANGES_EXIT: i32 = 3; |
| 69 | /// The exit code for a lockfile or ecosystem this cannot update. |
| 70 | pub const UNSUPPORTED_EXIT: i32 = 4; |
| 71 | |
| 72 | /// Why a bump stopped, when that is not just an error. |
| 73 | #[derive(Debug)] |
| 74 | enum Stop { |
| 75 | /// The tool could not raise it: something else holds it back. |
| 76 | NeedsChanges(String), |
| 77 | /// Not something this can update. |
| 78 | Unsupported(String), |
| 79 | } |
| 80 | |
| 81 | impl std::fmt::Display for Stop { |
| 82 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 83 | match self { |
| 84 | Stop::NeedsChanges(why) => write!(f, "needs code changes: {why}"), |
| 85 | Stop::Unsupported(why) => write!(f, "unsupported: {why}"), |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | impl std::error::Error for Stop {} |
| 91 | |
| 92 | fn needs_changes(why: impl Into<String>) -> anyhow::Error { |
| 93 | anyhow!(Stop::NeedsChanges(why.into())) |
| 94 | } |
| 95 | |
| 96 | fn unsupported(why: impl Into<String>) -> anyhow::Error { |
| 97 | anyhow!(Stop::Unsupported(why.into())) |
| 98 | } |
| 99 | |
| 100 | /// What to raise, to what, where. |
| 101 | #[derive(Debug)] |
| 102 | struct Bump { |
| 103 | ecosystem: Ecosystem, |
| 104 | package: String, |
| 105 | /// The fixed version, as the ecosystem's tools write it (Go's with `v`). |
| 106 | version: String, |
| 107 | jobs: Vec<Job>, |
| 108 | } |
| 109 | |
| 110 | /// One directory's lockfiles of one kind, updated by one tool run. |
| 111 | #[derive(Debug, PartialEq, Eq)] |
| 112 | struct Job { |
| 113 | /// From the repository's root; empty for the root. |
| 114 | dir: String, |
| 115 | lockfile: Lockfile, |
| 116 | /// The lockfiles' paths from the root, each read again afterwards. |
| 117 | paths: Vec<String>, |
| 118 | } |
| 119 | |
| 120 | impl Job { |
| 121 | fn file(&self, name: &str) -> String { |
| 122 | if self.dir.is_empty() { name.to_owned() } else { format!("{}/{name}", self.dir) } |
| 123 | } |
| 124 | |
| 125 | /// What the tool may change: the lockfiles and their manifest. Only |
| 126 | /// these are committed, whatever else a tool leaves behind. |
| 127 | fn touched(&self) -> Vec<String> { |
| 128 | let mut files: Vec<String> = self.paths.clone(); |
| 129 | let manifests: &[&str] = match self.lockfile { |
| 130 | Lockfile::PackageLock | Lockfile::PnpmLock | Lockfile::YarnLock => &["package.json"], |
| 131 | Lockfile::GoMod | Lockfile::GoSum => &["go.mod", "go.sum"], |
| 132 | Lockfile::PoetryLock => &["pyproject.toml"], |
| 133 | Lockfile::CargoLock | Lockfile::Requirements => &[], |
| 134 | }; |
| 135 | files.extend(manifests.iter().map(|name| self.file(name))); |
| 136 | files.sort(); |
| 137 | files.dedup(); |
| 138 | files |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | /// `BUMP_LOCKFILES`: a JSON array, or one path per line. |
| 143 | fn lockfile_list(text: &str) -> Result<Vec<String>> { |
| 144 | let text = text.trim(); |
| 145 | let paths: Vec<String> = if text.starts_with('[') { |
| 146 | serde_json::from_str(text).context("BUMP_LOCKFILES is not a JSON array of paths")? |
| 147 | } else { |
| 148 | text.lines().map(str::to_owned).collect() |
| 149 | }; |
| 150 | Ok(paths.into_iter().map(|path| path.trim().trim_start_matches("./").to_owned()).filter(|path| !path.is_empty()).collect()) |
| 151 | } |
| 152 | |
| 153 | /// The lockfiles grouped into what one tool run updates: `go.mod` and |
| 154 | /// `go.sum` in one directory are one module. Each must be a lockfile of |
| 155 | /// `ecosystem`, inside the repository. |
| 156 | fn jobs(ecosystem: Ecosystem, paths: &[String]) -> Result<Vec<Job>> { |
| 157 | let mut jobs: Vec<Job> = Vec::new(); |
| 158 | for path in paths { |
| 159 | if path.starts_with('/') || path.contains('\\') || path.split('/').any(|part| part == ".." || part.is_empty()) { |
| 160 | bail!("{path} is not a path inside the repository"); |
| 161 | } |
| 162 | let lockfile = Lockfile::for_path(path).ok_or_else(|| unsupported(format!("{path} is not a lockfile g1t can update")))?; |
| 163 | if lockfile.ecosystem() != ecosystem { |
| 164 | bail!("{path} is not a {} lockfile", ecosystem.osv()); |
| 165 | } |
| 166 | let dir = path.rsplit_once('/').map(|(dir, _)| dir.to_owned()).unwrap_or_default(); |
| 167 | let lockfile = if lockfile == Lockfile::GoSum { Lockfile::GoMod } else { lockfile }; |
| 168 | match jobs.iter_mut().find(|job| job.dir == dir && job.lockfile == lockfile) { |
| 169 | Some(job) => { |
| 170 | if !job.paths.contains(path) { |
| 171 | job.paths.push(path.clone()); |
| 172 | } |
| 173 | } |
| 174 | None => jobs.push(Job { dir, lockfile, paths: vec![path.clone()] }), |
| 175 | } |
| 176 | } |
| 177 | if jobs.is_empty() { |
| 178 | bail!("BUMP_LOCKFILES names no lockfile"); |
| 179 | } |
| 180 | Ok(jobs) |
| 181 | } |
| 182 | |
| 183 | /// A version as the ecosystem's tools take it: Go's with a leading `v`, |
| 184 | /// everyone else's without. |
| 185 | fn tool_version(ecosystem: Ecosystem, version: &str) -> String { |
| 186 | let version = version.trim(); |
| 187 | let bare = match version.strip_prefix(['v', 'V']) { |
| 188 | Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest, |
| 189 | _ => version, |
| 190 | }; |
| 191 | match ecosystem { |
| 192 | Ecosystem::Go => format!("v{bare}"), |
| 193 | _ => bare.to_owned(), |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | /// A package name or version is passed to tools as one argument: it must |
| 198 | /// not read as an option, and holds only what names and versions do. |
| 199 | fn safe_argument(what: &str, text: &str) -> Result<()> { |
| 200 | let allowed = |c: char| c.is_ascii_alphanumeric() || "@/._-+~".contains(c); |
| 201 | if text.is_empty() || text.starts_with('-') || !text.chars().all(allowed) || text.len() > 214 { |
| 202 | bail!("{what} {text:?} is not a package name or version g1t can pass to a tool"); |
| 203 | } |
| 204 | Ok(()) |
| 205 | } |
| 206 | |
| 207 | /// The range to ask for a direct dependency now at `current`: the same |
| 208 | /// style (`^1.2.3`, `~1.2.3`, exact), and `^` for any other. |
| 209 | fn raised_range(current: &str, version: &str) -> String { |
| 210 | let current = current.trim(); |
| 211 | if current.starts_with('~') { |
| 212 | format!("~{version}") |
| 213 | } else if current.starts_with(|c: char| c.is_ascii_digit()) || current.starts_with('=') { |
| 214 | version.to_owned() |
| 215 | } else { |
| 216 | format!("^{version}") |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | /// The range `package.json` asks for `package` with, if it is a direct |
| 221 | /// dependency from the registry (not a workspace, link, alias or URL). |
| 222 | fn direct_range(manifest: &Value, package: &str) -> Option<String> { |
| 223 | ["dependencies", "devDependencies", "optionalDependencies"].iter().find_map(|section| { |
| 224 | let range = manifest.get(section)?.get(package)?.as_str()?; |
| 225 | let registry = !range.contains(':') && !range.contains('/'); |
| 226 | registry.then(|| range.to_owned()) |
| 227 | }) |
| 228 | } |
| 229 | |
| 230 | /// Which JavaScript package manager wrote a lockfile. |
| 231 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 232 | enum Node { |
| 233 | Npm, |
| 234 | Pnpm, |
| 235 | /// Yarn 1. |
| 236 | YarnClassic, |
| 237 | /// Yarn 2 and later, whose lockfile has `__metadata`. |
| 238 | YarnBerry, |
| 239 | } |
| 240 | |
| 241 | impl Node { |
| 242 | fn of(lockfile: Lockfile, text: &str) -> Option<Node> { |
| 243 | Some(match lockfile { |
| 244 | Lockfile::PackageLock => Node::Npm, |
| 245 | Lockfile::PnpmLock => Node::Pnpm, |
| 246 | Lockfile::YarnLock if text.lines().any(|line| line.starts_with("__metadata:")) => Node::YarnBerry, |
| 247 | Lockfile::YarnLock => Node::YarnClassic, |
| 248 | _ => return None, |
| 249 | }) |
| 250 | } |
| 251 | |
| 252 | /// The command, through corepack for pnpm and yarn, so the version the |
| 253 | /// project's `packageManager` names is the one that runs. |
| 254 | fn command(self, args: &[&str]) -> Vec<String> { |
| 255 | let mut command: Vec<&str> = match self { |
| 256 | Node::Npm => vec!["npm"], |
| 257 | Node::Pnpm => vec!["corepack", "pnpm"], |
| 258 | Node::YarnClassic | Node::YarnBerry => vec!["corepack", "yarn"], |
| 259 | }; |
| 260 | command.extend(args); |
| 261 | command.into_iter().map(str::to_owned).collect() |
| 262 | } |
| 263 | |
| 264 | /// Raises a direct dependency to `range`. |
| 265 | fn direct(self, package: &str, range: &str) -> Vec<String> { |
| 266 | let spec = format!("{package}@{range}"); |
| 267 | match self { |
| 268 | Node::Npm => self.command(&["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", &spec]), |
| 269 | Node::Pnpm => self.command(&["update", &spec, "--lockfile-only", "--ignore-scripts"]), |
| 270 | Node::YarnBerry => self.command(&["up", &spec, "--mode=update-lockfile"]), |
| 271 | Node::YarnClassic => self.command(&["upgrade", &spec, "--ignore-scripts", "--non-interactive"]), |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | /// Moves a package something else depends on as far as the ranges |
| 276 | /// that ask for it allow. Yarn 1 has no such command. |
| 277 | fn transitive(self, package: &str) -> Option<Vec<String>> { |
| 278 | Some(match self { |
| 279 | Node::Npm => self.command(&["update", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", package]), |
| 280 | Node::Pnpm => self.command(&["update", package, "--depth", "Infinity", "--lockfile-only", "--ignore-scripts"]), |
| 281 | Node::YarnBerry => self.command(&["up", "--recursive", package, "--mode=update-lockfile"]), |
| 282 | Node::YarnClassic => return None, |
| 283 | }) |
| 284 | } |
| 285 | |
| 286 | /// Where `package.json` forces a version on everything that asks for |
| 287 | /// a package. |
| 288 | fn override_path(self) -> &'static [&'static str] { |
| 289 | match self { |
| 290 | Node::Npm => &["overrides"], |
| 291 | Node::Pnpm => &["pnpm", "overrides"], |
| 292 | Node::YarnClassic | Node::YarnBerry => &["resolutions"], |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | /// Writes the lockfile again from `package.json`. |
| 297 | fn relock(self) -> Vec<String> { |
| 298 | match self { |
| 299 | Node::Npm => self.command(&["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"]), |
| 300 | Node::Pnpm => self.command(&["install", "--lockfile-only", "--ignore-scripts"]), |
| 301 | Node::YarnBerry => self.command(&["install", "--mode=update-lockfile"]), |
| 302 | Node::YarnClassic => self.command(&["install", "--ignore-scripts", "--non-interactive"]), |
| 303 | } |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | /// Adds `package: version` to the overrides at `path` in `package.json`, |
| 308 | /// creating the objects on the way. Returns false when it is already there. |
| 309 | fn add_override(manifest: &mut Value, path: &[&str], package: &str, version: &str) -> Result<bool> { |
| 310 | let mut at = manifest; |
| 311 | for key in path { |
| 312 | let object = at.as_object_mut().ok_or_else(|| anyhow!("package.json is not an object"))?; |
| 313 | at = object.entry(key.to_string()).or_insert_with(|| json!({})); |
| 314 | } |
| 315 | let object = at.as_object_mut().ok_or_else(|| anyhow!("package.json's {} is not an object", path.join(".")))?; |
| 316 | if object.get(package).and_then(Value::as_str) == Some(version) { |
| 317 | return Ok(false); |
| 318 | } |
| 319 | object.insert(package.to_owned(), Value::String(version.to_owned())); |
| 320 | Ok(true) |
| 321 | } |
| 322 | |
| 323 | /// What Cargo runs for each locked version of `package` below `version`: |
| 324 | /// straight to it, or, when that is past what a dependent's requirement |
| 325 | /// allows, as far as the requirement does. |
| 326 | fn cargo_commands(package: &str, old: &str, version: &str) -> [Vec<String>; 2] { |
| 327 | let spec = format!("{package}@{old}"); |
| 328 | [ |
| 329 | ["cargo", "update", "-p", &spec, "--precise", version].map(str::to_owned).to_vec(), |
| 330 | ["cargo", "update", "-p", &spec].map(str::to_owned).to_vec(), |
| 331 | ] |
| 332 | } |
| 333 | |
| 334 | fn go_commands(module: &str, version: &str) -> [Vec<String>; 2] { |
| 335 | [ |
| 336 | vec!["go".into(), "get".into(), format!("{module}@{version}")], |
| 337 | ["go", "mod", "tidy"].map(str::to_owned).to_vec(), |
| 338 | ] |
| 339 | } |
| 340 | |
| 341 | /// Which dependency group of `pyproject.toml` names `package`: `Some(None)` |
| 342 | /// for the main one, `Some(Some(group))` for another, `None` when it is not |
| 343 | /// a direct dependency. |
| 344 | fn poetry_group(pyproject: &toml::Value, package: &str) -> Option<Option<String>> { |
| 345 | let wanted = Ecosystem::PyPI.normalize(package); |
| 346 | let names = |table: Option<&toml::Value>| -> bool { |
| 347 | table |
| 348 | .and_then(toml::Value::as_table) |
| 349 | .is_some_and(|table| table.keys().any(|key| Ecosystem::PyPI.normalize(key) == wanted)) |
| 350 | }; |
| 351 | let poetry = pyproject.get("tool").and_then(|tool| tool.get("poetry")); |
| 352 | if names(poetry.and_then(|poetry| poetry.get("dependencies"))) { |
| 353 | return Some(None); |
| 354 | } |
| 355 | let pep621 = pyproject |
| 356 | .get("project") |
| 357 | .and_then(|project| project.get("dependencies")) |
| 358 | .and_then(toml::Value::as_array) |
| 359 | .is_some_and(|list| { |
| 360 | list.iter().filter_map(toml::Value::as_str).any(|requirement| { |
| 361 | let name: String = requirement.chars().take_while(|c| c.is_ascii_alphanumeric() || "-_.".contains(*c)).collect(); |
| 362 | Ecosystem::PyPI.normalize(&name) == wanted |
| 363 | }) |
| 364 | }); |
| 365 | if pep621 { |
| 366 | return Some(None); |
| 367 | } |
| 368 | if names(poetry.and_then(|poetry| poetry.get("dev-dependencies"))) { |
| 369 | return Some(Some("dev".to_owned())); |
| 370 | } |
| 371 | let groups = poetry.and_then(|poetry| poetry.get("group")).and_then(toml::Value::as_table)?; |
| 372 | groups |
| 373 | .iter() |
| 374 | .find(|(_, group)| names(group.get("dependencies"))) |
| 375 | .map(|(name, _)| Some(name.clone())) |
| 376 | } |
| 377 | |
| 378 | fn poetry_commands(poetry: &[String], package: &str, version: &str, group: Option<Option<String>>) -> Vec<String> { |
| 379 | let mut command = poetry.to_vec(); |
| 380 | match group { |
| 381 | Some(group) => { |
| 382 | command.extend(["add".to_owned(), format!("{package}@^{version}"), "--lock".to_owned()]); |
| 383 | if let Some(group) = group { |
| 384 | command.extend(["--group".to_owned(), group]); |
| 385 | } |
| 386 | } |
| 387 | None => command.extend(["update".to_owned(), "--lock".to_owned(), package.to_owned()]), |
| 388 | } |
| 389 | command |
| 390 | } |
| 391 | |
| 392 | /// `requirements.txt` with every `==` (or `===`) pin of `package` below |
| 393 | /// `version` raised to it, and nothing else changed. Returns the text and |
| 394 | /// how many pins moved. |
| 395 | fn rewrite_pins(text: &str, package: &str, version: &str) -> (String, usize) { |
| 396 | let wanted = Ecosystem::PyPI.normalize(package); |
| 397 | let mut moved = 0; |
| 398 | let mut out = String::with_capacity(text.len() + 8); |
| 399 | for line in text.split_inclusive('\n') { |
| 400 | let rewritten = (|| { |
| 401 | let code = line.split('#').next().unwrap_or_default(); |
| 402 | let trimmed = code.trim_start(); |
| 403 | if trimmed.starts_with('-') || code.contains("://") { |
| 404 | return None; |
| 405 | } |
| 406 | let operator = code.find("===").map(|at| (at, 3)).or_else(|| code.find("==").map(|at| (at, 2)))?; |
| 407 | let name = code[..operator.0].split('[').next().unwrap_or_default().trim(); |
| 408 | if Ecosystem::PyPI.normalize(name) != wanted { |
| 409 | return None; |
| 410 | } |
| 411 | let start = operator.0 + operator.1; |
| 412 | let rest = &code[start..]; |
| 413 | let leading = rest.len() - rest.trim_start().len(); |
| 414 | let from = start + leading; |
| 415 | let end = code[from..] |
| 416 | .find(|c: char| c.is_whitespace() || ",;\\".contains(c)) |
| 417 | .map_or(code.len(), |at| from + at); |
| 418 | let old = &line[from..end]; |
| 419 | if old.is_empty() || old.contains('*') || version::compare(old, version).is_ge() { |
| 420 | return None; |
| 421 | } |
| 422 | Some(format!("{}{version}{}", &line[..from], &line[end..])) |
| 423 | })(); |
| 424 | match rewritten { |
| 425 | Some(line) => { |
| 426 | moved += 1; |
| 427 | out.push_str(&line); |
| 428 | } |
| 429 | None => out.push_str(line), |
| 430 | } |
| 431 | } |
| 432 | (out, moved) |
| 433 | } |
| 434 | |
| 435 | /// The versions of `package` a lockfile still resolves below `version`. |
| 436 | fn below(lockfile: Lockfile, text: &str, ecosystem: Ecosystem, package: &str, version: &str) -> Vec<String> { |
| 437 | let name = ecosystem.normalize(package); |
| 438 | let found: BTreeSet<String> = lockfile |
| 439 | .parse(text) |
| 440 | .into_iter() |
| 441 | .filter(|found| found.name == name && version::compare(&found.version, version).is_lt()) |
| 442 | .map(|found| found.version) |
| 443 | .collect(); |
| 444 | found.into_iter().collect() |
| 445 | } |
| 446 | |
| 447 | /// The last lines of what a tool said, for the reason it failed. |
| 448 | fn tail(text: &str, lines: usize) -> String { |
| 449 | let all: Vec<&str> = text.lines().filter(|line| !line.trim().is_empty()).collect(); |
| 450 | all[all.len().saturating_sub(lines)..].join("\n") |
| 451 | } |
| 452 | |
| 453 | /// Runs a tool in `dir` and returns what it said, failing with the last |
| 454 | /// lines of its output. A tool that is not installed is unsupported. |
| 455 | fn run(dir: &Path, command: &[String]) -> Result<String> { |
| 456 | let (program, args) = command.split_first().ok_or_else(|| anyhow!("no command"))?; |
| 457 | eprintln!("g1t-runner: {} (in {})", command.join(" "), dir.display()); |
| 458 | let output = Command::new(program) |
| 459 | .current_dir(dir) |
| 460 | .args(args) |
| 461 | // Lockfiles only: nothing installs, prompts, audits or runs scripts. |
| 462 | .env("CI", "true") |
| 463 | .env("npm_config_audit", "false") |
| 464 | .env("npm_config_fund", "false") |
| 465 | .env("npm_config_update_notifier", "false") |
| 466 | .env("npm_config_ignore_scripts", "true") |
| 467 | .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") |
| 468 | .env("YARN_ENABLE_IMMUTABLE_INSTALLS", "false") |
| 469 | .env("YARN_ENABLE_SCRIPTS", "false") |
| 470 | .env("YARN_ENABLE_TELEMETRY", "0") |
| 471 | .env("POETRY_NO_INTERACTION", "1") |
| 472 | .env("POETRY_VIRTUALENVS_CREATE", "false") |
| 473 | .env("GOFLAGS", "-mod=mod") |
| 474 | .output() |
| 475 | .map_err(|error| { |
| 476 | if error.kind() == std::io::ErrorKind::NotFound { |
| 477 | unsupported(format!("{program} is not installed in this sandbox")) |
| 478 | } else { |
| 479 | anyhow!("could not run {program}: {error}") |
| 480 | } |
| 481 | })?; |
| 482 | let said = format!("{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); |
| 483 | if !output.status.success() { |
| 484 | bail!("{} failed:\n{}", command.join(" "), tail(&said, 20)); |
| 485 | } |
| 486 | Ok(said) |
| 487 | } |
| 488 | |
| 489 | fn read(path: &Path) -> Result<String> { |
| 490 | std::fs::read_to_string(path).with_context(|| format!("could not read {}", path.display())) |
| 491 | } |
| 492 | |
| 493 | /// Poetry, installed into a virtual environment from PyPI when the |
| 494 | /// sandbox has none. |
| 495 | fn poetry() -> Result<Vec<String>> { |
| 496 | if Command::new("poetry").arg("--version").output().is_ok_and(|output| output.status.success()) { |
| 497 | return Ok(vec!["poetry".to_owned()]); |
| 498 | } |
| 499 | let venv = "/tmp/g1t-poetry"; |
| 500 | let here = Path::new("/"); |
| 501 | run(here, &["python3", "-m", "venv", venv].map(str::to_owned))?; |
| 502 | run(here, &[format!("{venv}/bin/pip"), "install".into(), "--quiet".into(), "poetry".into()])?; |
| 503 | Ok(vec![format!("{venv}/bin/poetry")]) |
| 504 | } |
| 505 | |
| 506 | impl Bump { |
| 507 | fn from_env() -> Result<Bump> { |
| 508 | let ecosystem_name = env("BUMP_ECOSYSTEM")?; |
| 509 | let ecosystem = Ecosystem::parse(ecosystem_name.trim()) |
| 510 | .ok_or_else(|| unsupported(format!("g1t cannot update {ecosystem_name} dependencies")))?; |
| 511 | let package = env("BUMP_PACKAGE")?.trim().to_owned(); |
| 512 | let version = tool_version(ecosystem, &env("BUMP_VERSION")?); |
| 513 | safe_argument("package", &package)?; |
| 514 | safe_argument("version", &version)?; |
| 515 | let jobs = jobs(ecosystem, &lockfile_list(&env("BUMP_LOCKFILES")?)?)?; |
| 516 | Ok(Bump { ecosystem, package, version, jobs }) |
| 517 | } |
| 518 | |
| 519 | /// The versions every lockfile of `job` still resolves below the target. |
| 520 | fn still_below(&self, workdir: &Path, job: &Job) -> Result<Vec<String>> { |
| 521 | let mut found = BTreeSet::new(); |
| 522 | for path in &job.paths { |
| 523 | let lockfile = Lockfile::for_path(path).unwrap_or(job.lockfile); |
| 524 | let file = workdir.join(path); |
| 525 | if !file.exists() { |
| 526 | bail!("{path} is not in the repository's default branch"); |
| 527 | } |
| 528 | found.extend(below(lockfile, &read(&file)?, self.ecosystem, &self.package, &self.version)); |
| 529 | } |
| 530 | Ok(found.into_iter().collect()) |
| 531 | } |
| 532 | |
| 533 | /// Runs the tool for one job. Errors from the tool are kept for the |
| 534 | /// reason, should the lockfile still be behind afterwards. |
| 535 | fn update(&self, workdir: &Path, job: &Job) -> Result<Vec<String>> { |
| 536 | let dir = workdir.join(&job.dir); |
| 537 | let mut said = Vec::new(); |
| 538 | let attempt = |command: Vec<String>, said: &mut Vec<String>| -> Result<bool> { |
| 539 | match run(&dir, &command) { |
| 540 | Ok(_) => Ok(true), |
| 541 | Err(error) if error.downcast_ref::<Stop>().is_some() => Err(error), |
| 542 | Err(error) => { |
| 543 | said.push(format!("{error:#}")); |
| 544 | Ok(false) |
| 545 | } |
| 546 | } |
| 547 | }; |
| 548 | match job.lockfile { |
| 549 | Lockfile::PackageLock | Lockfile::PnpmLock | Lockfile::YarnLock => { |
| 550 | let lock = read(&workdir.join(&job.paths[0]))?; |
| 551 | let node = Node::of(job.lockfile, &lock).ok_or_else(|| anyhow!("not a JavaScript lockfile"))?; |
| 552 | let manifest_path = dir.join("package.json"); |
| 553 | let mut manifest: Value = serde_json::from_str(&read(&manifest_path)?).context("package.json is not JSON")?; |
| 554 | match direct_range(&manifest, &self.package) { |
| 555 | Some(range) => { |
| 556 | attempt(node.direct(&self.package, &raised_range(&range, &self.version)), &mut said)?; |
| 557 | } |
| 558 | None => { |
| 559 | if let Some(command) = node.transitive(&self.package) { |
| 560 | attempt(command, &mut said)?; |
| 561 | } |
| 562 | // Held back by what asks for it: force the version. |
| 563 | if !self.still_below(workdir, job)?.is_empty() { |
| 564 | manifest = serde_json::from_str(&read(&manifest_path)?).context("package.json is not JSON")?; |
| 565 | if add_override(&mut manifest, node.override_path(), &self.package, &self.version)? { |
| 566 | let indent = if read(&manifest_path)?.contains("\n \"") { " " } else { " " }; |
| 567 | write_json(&manifest_path, &manifest, indent)?; |
| 568 | } |
| 569 | attempt(node.relock(), &mut said)?; |
| 570 | } |
| 571 | } |
| 572 | } |
| 573 | } |
| 574 | Lockfile::CargoLock => { |
| 575 | for old in self.still_below(workdir, job)? { |
| 576 | let [precise, compatible] = cargo_commands(&self.package, &old, &self.version); |
| 577 | if !attempt(precise, &mut said)? { |
| 578 | attempt(compatible, &mut said)?; |
| 579 | } |
| 580 | } |
| 581 | } |
| 582 | Lockfile::GoMod | Lockfile::GoSum => { |
| 583 | for command in go_commands(&self.package, &self.version) { |
| 584 | if !attempt(command, &mut said)? { |
| 585 | break; |
| 586 | } |
| 587 | } |
| 588 | } |
| 589 | Lockfile::PoetryLock => { |
| 590 | let pyproject_path = dir.join("pyproject.toml"); |
| 591 | let pyproject: toml::Value = toml::from_str(&read(&pyproject_path)?).context("pyproject.toml is not TOML")?; |
| 592 | let command = poetry_commands(&poetry()?, &self.package, &self.version, poetry_group(&pyproject, &self.package)); |
| 593 | attempt(command, &mut said)?; |
| 594 | } |
| 595 | Lockfile::Requirements => { |
| 596 | for path in &job.paths { |
| 597 | let file = workdir.join(path); |
| 598 | let text = read(&file)?; |
| 599 | if text.contains("--hash") { |
| 600 | return Err(unsupported(format!("{path} pins hashes, which need its compiler to update"))); |
| 601 | } |
| 602 | let (rewritten, moved) = rewrite_pins(&text, &self.package, &self.version); |
| 603 | if moved > 0 { |
| 604 | std::fs::write(&file, rewritten).with_context(|| format!("could not write {path}"))?; |
| 605 | } |
| 606 | } |
| 607 | } |
| 608 | } |
| 609 | Ok(said) |
| 610 | } |
| 611 | } |
| 612 | |
| 613 | /// Writes JSON as package managers do: indented, with a final newline. |
| 614 | fn write_json(path: &Path, value: &Value, indent: &str) -> Result<()> { |
| 615 | let mut out = Vec::new(); |
| 616 | let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); |
| 617 | let mut serializer = serde_json::Serializer::with_formatter(&mut out, formatter); |
| 618 | serde::Serialize::serialize(value, &mut serializer)?; |
| 619 | out.push(b'\n'); |
| 620 | std::fs::write(path, out).with_context(|| format!("could not write {}", path.display())) |
| 621 | } |
| 622 | |
| 623 | /// What was pushed. |
| 624 | struct Pushed { |
| 625 | commit: String, |
| 626 | /// The branch was there already, with the same files. |
| 627 | existed: bool, |
| 628 | } |
| 629 | |
| 630 | fn bump() -> Result<(Bump, String, Pushed)> { |
| 631 | let bump = Bump::from_env()?; |
| 632 | let remote = env("GIT_REMOTE")?; |
| 633 | let base = env("GIT_BRANCH_BASE")?; |
| 634 | let branch = env("GIT_BRANCH")?; |
| 635 | if !branch.starts_with(BRANCH_PREFIX) || branch.contains("..") || branch.chars().any(char::is_whitespace) { |
| 636 | bail!("{branch} is not a security update's branch"); |
| 637 | } |
| 638 | let message = env("COMMIT_MESSAGE").unwrap_or_else(|_| format!("Update {} to {}", bump.package, bump.version)); |
| 639 | let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?); |
| 640 | let workdir = Path::new(WORKDIR); |
| 641 | |
| 642 | std::fs::create_dir_all("/work")?; |
| 643 | crate::clone::clone(Path::new("/work"), &auth, &["--branch", &base], &remote, WORKDIR) |
| 644 | .with_context(|| format!("could not clone {base}"))?; |
| 645 | git(workdir, &["checkout", "--quiet", "-b", &branch])?; |
| 646 | git(workdir, &["config", "user.name", AUTHOR_NAME])?; |
| 647 | git(workdir, &["config", "user.email", AUTHOR_EMAIL])?; |
| 648 | |
| 649 | let mut behind = Vec::new(); |
| 650 | for job in &bump.jobs { |
| 651 | if bump.still_below(workdir, job)?.is_empty() { |
| 652 | continue; // Already at the version or later here. |
| 653 | } |
| 654 | let said = bump.update(workdir, job)?; |
| 655 | let left = bump.still_below(workdir, job)?; |
| 656 | if !left.is_empty() { |
| 657 | let why = said.last().map(|said| format!("\n{said}")).unwrap_or_default(); |
| 658 | behind.push(format!( |
| 659 | "{} still resolves {} {} (wanted {} or later){why}", |
| 660 | job.paths.join(", "), |
| 661 | bump.package, |
| 662 | left.join(", "), |
| 663 | bump.version |
| 664 | )); |
| 665 | } |
| 666 | } |
| 667 | if !behind.is_empty() { |
| 668 | return Err(needs_changes(behind.join("\n\n"))); |
| 669 | } |
| 670 | |
| 671 | let touched: Vec<String> = bump |
| 672 | .jobs |
| 673 | .iter() |
| 674 | .flat_map(Job::touched) |
| 675 | .filter(|path| workdir.join(path).exists()) |
| 676 | .collect(); |
| 677 | let mut add = vec!["add", "--"]; |
| 678 | add.extend(touched.iter().map(String::as_str)); |
| 679 | git(workdir, &add)?; |
| 680 | if git(workdir, &["diff", "--cached", "--name-only"])?.is_empty() { |
| 681 | bail!( |
| 682 | "nothing to change: {} already resolves {} {} or later", |
| 683 | bump.jobs.iter().flat_map(|job| job.paths.iter().map(String::as_str)).collect::<Vec<_>>().join(", "), |
| 684 | bump.package, |
| 685 | bump.version |
| 686 | ); |
| 687 | } |
| 688 | git(workdir, &["commit", "--quiet", "--message", &message])?; |
| 689 | let commit = git(workdir, &["rev-parse", "HEAD"])?; |
| 690 | let refspec = format!("HEAD:refs/heads/{branch}"); |
| 691 | let pushed = git(workdir, &["-c", &auth, "push", "--quiet", "origin", &refspec]); |
| 692 | if let Err(error) = pushed { |
| 693 | // Pushed before (a retried job): the same files there is success. |
| 694 | let theirs = git(workdir, &["-c", &auth, "ls-remote", "origin", &format!("refs/heads/{branch}")]).unwrap_or_default(); |
| 695 | if theirs.is_empty() { |
| 696 | return Err(error.context("could not push the update")); |
| 697 | } |
| 698 | crate::clone::fetch(workdir, &auth, "origin", &format!("refs/heads/{branch}")).context("could not read the branch already pushed")?; |
| 699 | let same = git(workdir, &["rev-parse", "FETCH_HEAD^{tree}"])? == git(workdir, &["rev-parse", "HEAD^{tree}"])?; |
| 700 | if !same { |
| 701 | return Err(error.context(format!("{branch} already exists with other changes"))); |
| 702 | } |
| 703 | let commit = git(workdir, &["rev-parse", "FETCH_HEAD"])?; |
| 704 | return Ok((bump, branch, Pushed { commit, existed: true })); |
| 705 | } |
| 706 | Ok((bump, branch, Pushed { commit, existed: false })) |
| 707 | } |
| 708 | |
| 709 | pub fn main() -> i32 { |
| 710 | match bump() { |
| 711 | Ok((bump, branch, pushed)) => { |
| 712 | println!( |
| 713 | "{}", |
| 714 | json!({ |
| 715 | "bump": if pushed.existed { "exists" } else { "pushed" }, |
| 716 | "branch": branch, |
| 717 | "commit": pushed.commit, |
| 718 | "ecosystem": bump.ecosystem.osv(), |
| 719 | "package": bump.package, |
| 720 | "version": bump.version, |
| 721 | "lockfiles": bump.jobs.iter().flat_map(|job| job.paths.clone()).collect::<Vec<_>>(), |
| 722 | }) |
| 723 | ); |
| 724 | 0 |
| 725 | } |
| 726 | Err(error) => { |
| 727 | let (reason, code) = match error.downcast_ref::<Stop>() { |
| 728 | Some(Stop::NeedsChanges(_)) => ("needs_code_changes", NEEDS_CHANGES_EXIT), |
| 729 | Some(Stop::Unsupported(_)) => ("unsupported", UNSUPPORTED_EXIT), |
| 730 | None => ("failed", 1), |
| 731 | }; |
| 732 | println!("{}", json!({ "bump": "failed", "reason": reason, "message": format!("{error:#}") })); |
| 733 | eprintln!("g1t-runner: {error:#}"); |
| 734 | code |
| 735 | } |
| 736 | } |
| 737 | } |
| 738 | |
| 739 | #[cfg(test)] |
| 740 | mod tests { |
| 741 | use super::*; |
| 742 | |
| 743 | fn strings(items: &[&str]) -> Vec<String> { |
| 744 | items.iter().map(|item| item.to_string()).collect() |
| 745 | } |
| 746 | |
| 747 | #[test] |
| 748 | fn lockfiles_come_as_lines_or_json() { |
| 749 | assert_eq!(lockfile_list("Cargo.lock\n web/package-lock.json \n\n").unwrap(), ["Cargo.lock", "web/package-lock.json"]); |
| 750 | assert_eq!(lockfile_list(r#"["./go.mod","go.sum"]"#).unwrap(), ["go.mod", "go.sum"]); |
| 751 | assert!(lockfile_list("[not json").is_err()); |
| 752 | } |
| 753 | |
| 754 | #[test] |
| 755 | fn lockfiles_group_by_directory_and_tool() { |
| 756 | let found = jobs(Ecosystem::Go, &strings(&["go.mod", "go.sum", "tools/go.sum"])).unwrap(); |
| 757 | assert_eq!( |
| 758 | found, |
| 759 | [ |
| 760 | Job { dir: String::new(), lockfile: Lockfile::GoMod, paths: strings(&["go.mod", "go.sum"]) }, |
| 761 | Job { dir: "tools".into(), lockfile: Lockfile::GoMod, paths: strings(&["tools/go.sum"]) }, |
| 762 | ] |
| 763 | ); |
| 764 | assert_eq!(found[0].touched(), ["go.mod", "go.sum"]); |
| 765 | let web = jobs(Ecosystem::Npm, &strings(&["web/package-lock.json"])).unwrap(); |
| 766 | assert_eq!(web[0].touched(), ["web/package-lock.json", "web/package.json"]); |
| 767 | } |
| 768 | |
| 769 | #[test] |
| 770 | fn lockfiles_must_be_the_ecosystems_and_inside_the_repository() { |
| 771 | assert!(jobs(Ecosystem::Npm, &strings(&["Cargo.lock"])).is_err()); |
| 772 | assert!(jobs(Ecosystem::Npm, &strings(&["../package-lock.json"])).is_err()); |
| 773 | assert!(jobs(Ecosystem::Npm, &strings(&["/etc/package-lock.json"])).is_err()); |
| 774 | assert!(jobs(Ecosystem::Npm, &[]).is_err()); |
| 775 | let error = jobs(Ecosystem::PyPI, &strings(&["uv.lock"])).unwrap_err(); |
| 776 | assert!(matches!(error.downcast_ref::<Stop>(), Some(Stop::Unsupported(_)))); |
| 777 | } |
| 778 | |
| 779 | #[test] |
| 780 | fn versions_as_each_tool_takes_them() { |
| 781 | assert_eq!(tool_version(Ecosystem::Go, "0.17.0"), "v0.17.0"); |
| 782 | assert_eq!(tool_version(Ecosystem::Go, "v0.17.0"), "v0.17.0"); |
| 783 | assert_eq!(tool_version(Ecosystem::Npm, "v4.17.21"), "4.17.21"); |
| 784 | assert_eq!(tool_version(Ecosystem::Cargo, " 1.6.1 "), "1.6.1"); |
| 785 | assert_eq!(tool_version(Ecosystem::PyPI, "2.31.0"), "2.31.0"); |
| 786 | } |
| 787 | |
| 788 | #[test] |
| 789 | fn names_and_versions_cannot_be_options() { |
| 790 | assert!(safe_argument("package", "@babel/core").is_ok()); |
| 791 | assert!(safe_argument("package", "golang.org/x/net").is_ok()); |
| 792 | assert!(safe_argument("version", "1.2.3-rc.1+build").is_ok()); |
| 793 | assert!(safe_argument("package", "--registry=evil").is_err()); |
| 794 | assert!(safe_argument("package", "a b").is_err()); |
| 795 | assert!(safe_argument("version", "1.0;rm").is_err()); |
| 796 | assert!(safe_argument("version", "").is_err()); |
| 797 | } |
| 798 | |
| 799 | #[test] |
| 800 | fn a_direct_dependency_keeps_its_range_style() { |
| 801 | assert_eq!(raised_range("^4.17.0", "4.17.21"), "^4.17.21"); |
| 802 | assert_eq!(raised_range("~1.2.0", "1.2.5"), "~1.2.5"); |
| 803 | assert_eq!(raised_range("1.2.0", "1.2.5"), "1.2.5"); |
| 804 | assert_eq!(raised_range("=1.2.0", "1.2.5"), "1.2.5"); |
| 805 | assert_eq!(raised_range(">=1 <2", "1.2.5"), "^1.2.5"); |
| 806 | assert_eq!(raised_range("*", "1.2.5"), "^1.2.5"); |
| 807 | } |
| 808 | |
| 809 | #[test] |
| 810 | fn direct_dependencies_from_the_registry_only() { |
| 811 | let manifest = json!({ |
| 812 | "dependencies": { "lodash": "^4.17.0", "local": "file:../local", "shared": "workspace:*" }, |
| 813 | "devDependencies": { "vitest": "~1.0.0", "fork": "github:me/fork" }, |
| 814 | }); |
| 815 | assert_eq!(direct_range(&manifest, "lodash").as_deref(), Some("^4.17.0")); |
| 816 | assert_eq!(direct_range(&manifest, "vitest").as_deref(), Some("~1.0.0")); |
| 817 | assert_eq!(direct_range(&manifest, "local"), None); |
| 818 | assert_eq!(direct_range(&manifest, "shared"), None); |
| 819 | assert_eq!(direct_range(&manifest, "fork"), None); |
| 820 | assert_eq!(direct_range(&manifest, "minimist"), None); |
| 821 | } |
| 822 | |
| 823 | #[test] |
| 824 | fn javascript_commands_by_lockfile() { |
| 825 | let npm = Node::of(Lockfile::PackageLock, "{}").unwrap(); |
| 826 | assert_eq!( |
| 827 | npm.direct("lodash", "^4.17.21"), |
| 828 | strings(&["npm", "install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", "lodash@^4.17.21"]) |
| 829 | ); |
| 830 | assert_eq!( |
| 831 | npm.transitive("minimist").unwrap(), |
| 832 | strings(&["npm", "update", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", "minimist"]) |
| 833 | ); |
| 834 | assert_eq!(npm.override_path(), ["overrides"]); |
| 835 | |
| 836 | let pnpm = Node::of(Lockfile::PnpmLock, "lockfileVersion: '9.0'").unwrap(); |
| 837 | assert_eq!(pnpm.direct("lodash", "^4.17.21"), strings(&["corepack", "pnpm", "update", "lodash@^4.17.21", "--lockfile-only", "--ignore-scripts"])); |
| 838 | assert_eq!(pnpm.override_path(), ["pnpm", "overrides"]); |
| 839 | |
| 840 | let berry = Node::of(Lockfile::YarnLock, "__metadata:\n version: 6\n").unwrap(); |
| 841 | assert_eq!(berry, Node::YarnBerry); |
| 842 | assert_eq!(berry.direct("lodash", "^4.17.21"), strings(&["corepack", "yarn", "up", "lodash@^4.17.21", "--mode=update-lockfile"])); |
| 843 | assert_eq!(berry.transitive("minimist").unwrap(), strings(&["corepack", "yarn", "up", "--recursive", "minimist", "--mode=update-lockfile"])); |
| 844 | |
| 845 | let classic = Node::of(Lockfile::YarnLock, "# yarn lockfile v1\n").unwrap(); |
| 846 | assert_eq!(classic, Node::YarnClassic); |
| 847 | assert_eq!(classic.transitive("minimist"), None); |
| 848 | assert_eq!(classic.override_path(), ["resolutions"]); |
| 849 | assert_eq!(Node::of(Lockfile::CargoLock, ""), None); |
| 850 | } |
| 851 | |
| 852 | #[test] |
| 853 | fn overrides_are_added_once() { |
| 854 | let mut manifest = json!({ "name": "app" }); |
| 855 | assert!(add_override(&mut manifest, &["pnpm", "overrides"], "minimist", "1.2.6").unwrap()); |
| 856 | assert_eq!(manifest["pnpm"]["overrides"]["minimist"], "1.2.6"); |
| 857 | assert!(!add_override(&mut manifest, &["pnpm", "overrides"], "minimist", "1.2.6").unwrap()); |
| 858 | assert!(add_override(&mut manifest, &["pnpm", "overrides"], "minimist", "1.2.8").unwrap()); |
| 859 | assert_eq!(manifest["pnpm"]["overrides"]["minimist"], "1.2.8"); |
| 860 | } |
| 861 | |
| 862 | #[test] |
| 863 | fn cargo_and_go_commands() { |
| 864 | let [precise, compatible] = cargo_commands("time", "0.1.43", "0.1.45"); |
| 865 | assert_eq!(precise, strings(&["cargo", "update", "-p", "time@0.1.43", "--precise", "0.1.45"])); |
| 866 | assert_eq!(compatible, strings(&["cargo", "update", "-p", "time@0.1.43"])); |
| 867 | let [get, tidy] = go_commands("golang.org/x/net", "v0.23.0"); |
| 868 | assert_eq!(get, strings(&["go", "get", "golang.org/x/net@v0.23.0"])); |
| 869 | assert_eq!(tidy, strings(&["go", "mod", "tidy"])); |
| 870 | } |
| 871 | |
| 872 | #[test] |
| 873 | fn poetry_adds_a_direct_dependency_and_updates_any_other() { |
| 874 | let pyproject: toml::Value = toml::from_str( |
| 875 | "[tool.poetry.dependencies]\npython = \"^3.11\"\nRequests = \"^2.0\"\n\n[tool.poetry.group.test.dependencies]\npytest = \"^7\"\n", |
| 876 | ) |
| 877 | .unwrap(); |
| 878 | assert_eq!(poetry_group(&pyproject, "requests"), Some(None)); |
| 879 | assert_eq!(poetry_group(&pyproject, "pytest"), Some(Some("test".to_owned()))); |
| 880 | assert_eq!(poetry_group(&pyproject, "urllib3"), None); |
| 881 | let pep621: toml::Value = toml::from_str("[project]\ndependencies = [\"jinja2>=3.0\", \"Flask_Cors\"]\n").unwrap(); |
| 882 | assert_eq!(poetry_group(&pep621, "Jinja2"), Some(None)); |
| 883 | assert_eq!(poetry_group(&pep621, "flask-cors"), Some(None)); |
| 884 | |
| 885 | let poetry = strings(&["poetry"]); |
| 886 | assert_eq!(poetry_commands(&poetry, "requests", "2.31.0", Some(None)), strings(&["poetry", "add", "requests@^2.31.0", "--lock"])); |
| 887 | assert_eq!( |
| 888 | poetry_commands(&poetry, "pytest", "7.4.0", Some(Some("test".into()))), |
| 889 | strings(&["poetry", "add", "pytest@^7.4.0", "--lock", "--group", "test"]) |
| 890 | ); |
| 891 | assert_eq!(poetry_commands(&poetry, "urllib3", "2.0.7", None), strings(&["poetry", "update", "--lock", "urllib3"])); |
| 892 | } |
| 893 | |
| 894 | #[test] |
| 895 | fn requirements_pins_are_rewritten_in_place() { |
| 896 | let text = "# pinned\nrequests==2.25.0 # http\nDjango[argon2]===3.2.0 ; python_version >= \"3.8\"\nurllib3==1.26.18\nrequests_toolbelt==0.9.1\n-r base.txt\nflask>=2.0\n"; |
| 897 | let (out, moved) = rewrite_pins(text, "requests", "2.31.0"); |
| 898 | assert_eq!(moved, 1); |
| 899 | assert!(out.contains("requests==2.31.0 # http\n")); |
| 900 | assert!(out.contains("requests_toolbelt==0.9.1\n")); |
| 901 | let (out, moved) = rewrite_pins(&out, "django", "3.2.25"); |
| 902 | assert_eq!(moved, 1); |
| 903 | assert!(out.contains("Django[argon2]===3.2.25 ; python_version >= \"3.8\"\n")); |
| 904 | // Already at or past the version: left alone. |
| 905 | let (same, moved) = rewrite_pins(text, "urllib3", "1.26.18"); |
| 906 | assert_eq!((same.as_str(), moved), (text, 0)); |
| 907 | // A line without a final newline keeps it that way. |
| 908 | assert_eq!(rewrite_pins("Requests==2.0", "requests", "2.31.0").0, "Requests==2.31.0"); |
| 909 | assert_eq!(rewrite_pins("requests == 2.0,<3\n", "requests", "2.31.0").0, "requests == 2.31.0,<3\n"); |
| 910 | } |
| 911 | |
| 912 | #[test] |
| 913 | fn lockfiles_are_read_again_for_what_is_still_below() { |
| 914 | let lock = r#"{"lockfileVersion":3,"packages":{"":{},"node_modules/lodash":{"version":"4.17.21"},"node_modules/a/node_modules/lodash":{"version":"4.17.4"}}}"#; |
| 915 | assert_eq!(below(Lockfile::PackageLock, lock, Ecosystem::Npm, "lodash", "4.17.21"), ["4.17.4"]); |
| 916 | let sum = "golang.org/x/net v0.17.0 h1:x=\ngolang.org/x/net v0.23.0 h1:y=\n"; |
| 917 | assert!(below(Lockfile::GoSum, sum, Ecosystem::Go, "golang.org/x/net", "v0.23.0").is_empty()); |
| 918 | assert_eq!(below(Lockfile::Requirements, "Requests==2.0\n", Ecosystem::PyPI, "requests", "2.31.0"), ["2.0"]); |
| 919 | } |
| 920 | |
| 921 | #[test] |
| 922 | fn a_failure_says_its_last_lines() { |
| 923 | assert_eq!(tail("a\n\nb\nc\n", 2), "b\nc"); |
| 924 | assert_eq!(tail("only", 5), "only"); |
| 925 | } |
| 926 | } |