g1t/crates/runner/src/bump.rs

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