flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/runner/src/bump.rs

926 lines41,887 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
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}