g1t/crates/runner/src/bump.rs

923 lines41,735 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
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent59use crate::{AUTHOR_EMAIL, AUTHOR_NAME};
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily60/// 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}