g1t/crates/runner/src/bump.rs

926 lines41,887 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1//! 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
59/// g1t's own name and address on the commits it makes:
60/// `g1t_contracts::system::{USERNAME, EMAIL}`.
61const AUTHOR_NAME: &str = "g1t";
62const AUTHOR_EMAIL: &str = "g1t@users.noreply.g1t.sh";
63/// Every security update's branch starts with this:
64/// `g1t_contracts::security::UPDATE_BRANCH_PREFIX`.
65const BRANCH_PREFIX: &str = "g1t/security/";
66
67/// The exit code when the update needs code changes, not just a lockfile.
68pub const NEEDS_CHANGES_EXIT: i32 = 3;
69/// The exit code for a lockfile or ecosystem this cannot update.
70pub const UNSUPPORTED_EXIT: i32 = 4;
71
72/// Why a bump stopped, when that is not just an error.
73#[derive(Debug)]
74enum 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
81impl 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
90impl std::error::Error for Stop {}
91
92fn needs_changes(why: impl Into<String>) -> anyhow::Error {
93 anyhow!(Stop::NeedsChanges(why.into()))
94}
95
96fn 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)]
102struct 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)]
112struct 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
120impl 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.
143fn 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.
156fn 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.
185fn 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.
199fn 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.
209fn 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).
222fn 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)]
232enum Node {
233 Npm,
234 Pnpm,
235 /// Yarn 1.
236 YarnClassic,
237 /// Yarn 2 and later, whose lockfile has `__metadata`.
238 YarnBerry,
239}
240
241impl 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.
309fn 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.
326fn 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
334fn 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.
344fn 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
378fn 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.
395fn 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`.
436fn 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.
448fn 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.
455fn 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
489fn 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.
495fn 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
506impl 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.
614fn 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.
624struct Pushed {
625 commit: String,
626 /// The branch was there already, with the same files.
627 existed: bool,
628}
629
630fn 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
709pub 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)]
740mod 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}