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.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1 | //! Makes a security or version update: raises one or more packages to a |
| 2 | //! version in the lockfiles named, with the ecosystem's own tool, commits | |
| 3 | //! that as g1t and pushes it to a branch of its own. The push is what tells | |
| 4 | //! the security service to open the pull request; this opens nothing | |
| 5 | //! itself. | |
| 6 | //! | |
| 7 | //! A security update raises one package to a fixed version, on a branch | |
| 8 | //! under `g1t/security/`. A version update (`BUMP_KIND=version`) raises one | |
| 9 | //! package or a group of them in one commit, on the branch its dependency | |
| 10 | //! update file names (any but the default one), and changes manifests as | |
| 11 | //! its versioning strategy says. | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 12 | //! |
| 13 | //! What each lockfile is updated with (the lockfiles `g1t_scan::lockfiles` | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 14 | //! reads), for a security update and a version update with the `increase` |
| 15 | //! strategy: | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 16 | //! |
| 17 | //! | Lockfile | A direct dependency | Any other | | |
| 18 | //! | --- | --- | --- | | |
| 19 | //! | `package-lock.json` | `npm install --package-lock-only <pkg>@<range>` | `npm update --package-lock-only <pkg>`, then an `overrides` entry | | |
| 20 | //! | `pnpm-lock.yaml` | `pnpm update <pkg>@<range> --lockfile-only` | `pnpm update <pkg> --depth Infinity --lockfile-only`, then `pnpm.overrides` | | |
| 21 | //! | `yarn.lock` (2 and later) | `yarn up <pkg>@<range> --mode=update-lockfile` | `yarn up --recursive <pkg>`, then `resolutions` | | |
| 22 | //! | `yarn.lock` (1) | `yarn upgrade <pkg>@<range>` | `resolutions` | | |
| 23 | //! | `Cargo.lock` | `cargo update -p <pkg>@<old> --precise <version>`, else `cargo update -p <pkg>@<old>` | the same | | |
| 24 | //! | `go.mod`, `go.sum` | `go get <module>@v<version>`, then `go mod tidy` | the same | | |
| 25 | //! | `poetry.lock` | `poetry add <pkg>@^<version> --lock` | `poetry update --lock <pkg>` | | |
| 26 | //! | `requirements.txt` | its `==` pins rewritten | the same | | |
| 27 | //! | |
| 28 | //! A direct dependency keeps its range's style (`^`, `~` or exact). No | |
| 29 | //! install script runs. pnpm and yarn run through corepack, so the | |
| 30 | //! version a project names in `packageManager` is the one used. Poetry is | |
| 31 | //! installed into a virtual environment if the sandbox has none. | |
| 32 | //! | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 33 | //! A version update never adds an override or a resolution: a package |
| 34 | //! nothing in `package.json` names is moved only as far as the ranges that | |
| 35 | //! ask for it allow. Its strategy (`BUMP_STRATEGY`) decides how a direct | |
| 36 | //! dependency's requirement changes: | |
| 37 | //! | |
| 38 | //! | Strategy | npm, pnpm, yarn | Cargo | Poetry | | |
| 39 | //! | --- | --- | --- | --- | | |
| 40 | //! | `increase` | the range raised, as above | `--precise`; if the requirement does not allow it, the requirement raised in `Cargo.toml` | as above | | |
| 41 | //! | `increase-if-necessary` | the update the range allows (`npm update`, `pnpm update`, `yarn up -R`, `yarn upgrade <pkg>`); if that is not enough, the range raised | as `increase` | as above | | |
| 42 | //! | `widen` | the update the range allows; if that is not enough, the range widened: `^1.2.0 \|\| ^2.0.0` | as `increase` | as above | | |
| 43 | //! | `lockfile-only` | the update the range allows, and `package.json` left alone | `--precise`, else as far as the requirement allows | `poetry update --lock <pkg>` | | |
| 44 | //! | |
| 45 | //! Go modules and `requirements.txt` are updated the same way whatever the | |
| 46 | //! strategy. A requirement raised in `Cargo.toml` keeps its operator (`^`, | |
| 47 | //! `~`, `=`, `>=` or none) and is found in the lockfile's directory and in | |
| 48 | //! the workspace members it lists by path or by a trailing `/*`: in | |
| 49 | //! `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]`, their | |
| 50 | //! `[target.*]` forms and `[workspace.dependencies]`, written as | |
| 51 | //! `name = "1.2"`, `name = { version = "1.2", … }` on one line, or a | |
| 52 | //! `[dependencies.name]` table. A requirement with more than one part | |
| 53 | //! (`>=1, <2`) is left alone. | |
| 54 | //! | |
| 55 | //! Private registries (`BUMP_REGISTRIES`) are written where the tools read | |
| 56 | //! them, outside the clone, so they are never committed; credentials are | |
| 57 | //! never printed: | |
| 58 | //! | |
| 59 | //! - `npm-registry`: a user npmrc (`NPM_CONFIG_USERCONFIG`, read by npm and | |
| 60 | //! pnpm) with the registry's `_authToken` or `_auth`, `registry=` when it | |
| 61 | //! replaces npm's and `@scope:registry=` for each scope. Yarn 2 and later | |
| 62 | //! is given only a registry that replaces npm's | |
| 63 | //! (`YARN_NPM_REGISTRY_SERVER` and its token or identity); its scopes are | |
| 64 | //! not configured. | |
| 65 | //! - `cargo-registry`: a token (`token`, else `password`) as | |
| 66 | //! `CARGO_REGISTRIES_<NAME>_TOKEN` for each registry the project's | |
| 67 | //! `.cargo/config.toml` names with the same index; one that replaces | |
| 68 | //! crates.io is also given to every cargo run as source replacement | |
| 69 | //! (`cargo --config <file>`). A registry the project does not name and | |
| 70 | //! that replaces nothing is not reachable. | |
| 71 | //! - `python-index`: `PIP_INDEX_URL` when it replaces PyPI, otherwise | |
| 72 | //! `PIP_EXTRA_INDEX_URL`, with the credentials in the URL; and | |
| 73 | //! `POETRY_HTTP_BASIC_<NAME>_USERNAME`/`_PASSWORD` for each source in | |
| 74 | //! `pyproject.toml` with the same URL. | |
| 75 | //! - `goproxy-server`: `GOPROXY=<url>,https://proxy.golang.org,direct` | |
| 76 | //! (`<url>,direct` when it replaces the public proxy) and a netrc entry | |
| 77 | //! (`NETRC`) for its host. The checksum database is not changed, so a | |
| 78 | //! private module it does not know still fails to verify. | |
| 79 | //! | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 80 | //! Afterwards every lockfile is read again, and the update counts only if |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 81 | //! none of them resolves a package below its version any more. When the |
| 82 | //! tool cannot get there (another package holds it back, or the strategy | |
| 83 | //! does not allow the change it needs), the job fails saying it needs code | |
| 84 | //! changes, with the tool's last lines. | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 85 | //! |
| 86 | //! Configuration: | |
| 87 | //! | |
| 88 | //! - `GIT_REMOTE`, `GIT_BRANCH_BASE`: the repository and its default branch. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 89 | //! - `GIT_BRANCH`: the branch to push: under `g1t/security/` for a security |
| 90 | //! update; for a version update, any name git takes but the default | |
| 91 | //! branch. | |
| 92 | //! - `BUMP_KIND`: `security` (the default) or `version`. | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 93 | //! - `BUMP_ECOSYSTEM` (OSV's name: `npm`, `crates.io`, `Go`, `PyPI`), |
| 94 | //! `BUMP_PACKAGE`, `BUMP_VERSION`: what to raise, to what. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 95 | //! - `BUMP_PACKAGES`: several packages raised in one commit, as a JSON |
| 96 | //! array of `{"package", "version"}`; `BUMP_PACKAGE` and `BUMP_VERSION` | |
| 97 | //! when absent or empty. | |
| 98 | //! - `BUMP_STRATEGY`: a version update's versioning strategy, `increase` | |
| 99 | //! by default. A security update always updates as the first table says. | |
| 100 | //! - `BUMP_FORCE`: `1` to replace the branch if it is there already. | |
| 101 | //! - `BUMP_REGISTRIES`: private registries, as a JSON array of | |
| 102 | //! `{"type", "url", "username", "password", "token", "replacesBase", | |
| 103 | //! "scopes"}`. | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 104 | //! - `BUMP_LOCKFILES`: the lockfiles' paths from the root, one per line or |
| 105 | //! as a JSON array. | |
| 106 | //! - `COMMIT_MESSAGE`: the commit's message. | |
| 107 | //! - `G1T_USER`, `G1T_TOKEN`: to clone and push; passed per command, never | |
| 108 | //! written to the clone's config or remote. | |
| 109 | //! | |
| 110 | //! It prints one line of JSON on stdout saying what happened, and exits 0 | |
| 111 | //! once the branch is pushed; otherwise non-zero, with why on stderr: | |
| 112 | //! `NEEDS_CHANGES_EXIT` when the update needs code changes, | |
| 113 | //! `UNSUPPORTED_EXIT` for a lockfile or tool it cannot update. | |
| 114 | ||
| 115 | use std::collections::BTreeSet; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 116 | use std::path::{Path, PathBuf}; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 117 | use std::process::Command; |
| 118 | ||
| 119 | use anyhow::{Context, Result, anyhow, bail}; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 120 | use base64::Engine; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 121 | use g1t_scan::lockfiles::{Ecosystem, Lockfile}; |
| 122 | use g1t_scan::version; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 123 | use serde::{Deserialize, Serialize}; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 124 | use serde_json::{Value, json}; |
| 125 | ||
| 126 | use crate::{WORKDIR, auth_option, env, git}; | |
| 127 | ||
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 128 | use crate::{AUTHOR_EMAIL, AUTHOR_NAME}; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 129 | /// Every security update's branch starts with this: |
| 130 | /// `g1t_contracts::security::UPDATE_BRANCH_PREFIX`. | |
| 131 | const BRANCH_PREFIX: &str = "g1t/security/"; | |
| 132 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 133 | /// Where the private registries' configuration is written: outside the |
| 134 | /// clone, so it is never committed. | |
| 135 | const REGISTRY_DIR: &str = "/tmp/g1t-registries"; | |
| 136 | ||
| 137 | /// The most packages one update raises. | |
| 138 | const MAX_PACKAGES: usize = 50; | |
| 139 | ||
| 140 | /// Why a version update with the `lockfile-only` strategy stopped short. | |
| 141 | const LOCKFILE_ONLY: &str = "needs a manifest change, which lockfile-only does not make"; | |
| 142 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 143 | /// The exit code when the update needs code changes, not just a lockfile. |
| 144 | pub const NEEDS_CHANGES_EXIT: i32 = 3; | |
| 145 | /// The exit code for a lockfile or ecosystem this cannot update. | |
| 146 | pub const UNSUPPORTED_EXIT: i32 = 4; | |
| 147 | ||
| 148 | /// Why a bump stopped, when that is not just an error. | |
| 149 | #[derive(Debug)] | |
| 150 | enum Stop { | |
| 151 | /// The tool could not raise it: something else holds it back. | |
| 152 | NeedsChanges(String), | |
| 153 | /// Not something this can update. | |
| 154 | Unsupported(String), | |
| 155 | } | |
| 156 | ||
| 157 | impl std::fmt::Display for Stop { | |
| 158 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 159 | match self { | |
| 160 | Stop::NeedsChanges(why) => write!(f, "needs code changes: {why}"), | |
| 161 | Stop::Unsupported(why) => write!(f, "unsupported: {why}"), | |
| 162 | } | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | impl std::error::Error for Stop {} | |
| 167 | ||
| 168 | fn needs_changes(why: impl Into<String>) -> anyhow::Error { | |
| 169 | anyhow!(Stop::NeedsChanges(why.into())) | |
| 170 | } | |
| 171 | ||
| 172 | fn unsupported(why: impl Into<String>) -> anyhow::Error { | |
| 173 | anyhow!(Stop::Unsupported(why.into())) | |
| 174 | } | |
| 175 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 176 | /// A security update or a version update. |
| 177 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 178 | enum Kind { | |
| 179 | Security, | |
| 180 | Version, | |
| 181 | } | |
| 182 | ||
| 183 | impl Kind { | |
| 184 | fn parse(text: &str) -> Result<Kind> { | |
| 185 | match text.trim() { | |
| 186 | "" | "security" => Ok(Kind::Security), | |
| 187 | "version" => Ok(Kind::Version), | |
| 188 | other => bail!("g1t cannot make a {other} update"), | |
| 189 | } | |
| 190 | } | |
| 191 | ||
| 192 | fn name(self) -> &'static str { | |
| 193 | match self { | |
| 194 | Kind::Security => "security", | |
| 195 | Kind::Version => "version", | |
| 196 | } | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | /// How a version update changes a direct dependency's requirement. | |
| 201 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 202 | enum Strategy { | |
| 203 | /// Raise the requirement to the version. | |
| 204 | Increase, | |
| 205 | /// Only when what it allows is not enough. | |
| 206 | IncreaseIfNecessary, | |
| 207 | /// Allow the version as well as what it allowed. | |
| 208 | Widen, | |
| 209 | /// Never: only the lockfile changes. | |
| 210 | LockfileOnly, | |
| 211 | } | |
| 212 | ||
| 213 | impl Strategy { | |
| 214 | fn parse(text: &str) -> Result<Strategy> { | |
| 215 | Ok(match text.trim() { | |
| 216 | "" | "increase" => Strategy::Increase, | |
| 217 | "increase-if-necessary" => Strategy::IncreaseIfNecessary, | |
| 218 | "widen" => Strategy::Widen, | |
| 219 | "lockfile-only" => Strategy::LockfileOnly, | |
| 220 | other => bail!("{other} is not a versioning strategy"), | |
| 221 | }) | |
| 222 | } | |
| 223 | } | |
| 224 | ||
| 225 | /// A package and the version to raise it to. | |
| 226 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 227 | struct Target { | |
| 228 | package: String, | |
| 229 | /// As the ecosystem's tools write it (Go's with `v`). | |
| 230 | version: String, | |
| 231 | } | |
| 232 | ||
| 233 | /// A private registry the tools may read: a `BUMP_REGISTRIES` entry. | |
| 234 | #[derive(Clone, Default, PartialEq, Eq, Deserialize)] | |
| 235 | #[serde(rename_all = "camelCase")] | |
| 236 | struct Registry { | |
| 237 | /// `npm-registry`, `cargo-registry`, `python-index` or `goproxy-server`. | |
| 238 | #[serde(rename = "type")] | |
| 239 | kind: String, | |
| 240 | url: String, | |
| 241 | #[serde(default)] | |
| 242 | username: Option<String>, | |
| 243 | #[serde(default)] | |
| 244 | password: Option<String>, | |
| 245 | #[serde(default)] | |
| 246 | token: Option<String>, | |
| 247 | /// Used in place of the ecosystem's public registry. | |
| 248 | #[serde(default, alias = "replaces_base")] | |
| 249 | replaces_base: bool, | |
| 250 | /// npm scopes it serves: `@acme`. | |
| 251 | #[serde(default)] | |
| 252 | scopes: Vec<String>, | |
| 253 | } | |
| 254 | ||
| 255 | /// Never shows the credentials. | |
| 256 | impl std::fmt::Debug for Registry { | |
| 257 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 258 | f.debug_struct("Registry") | |
| 259 | .field("kind", &self.kind) | |
| 260 | .field("url", &self.url) | |
| 261 | .field("replaces_base", &self.replaces_base) | |
| 262 | .field("scopes", &self.scopes) | |
| 263 | .finish_non_exhaustive() | |
| 264 | } | |
| 265 | } | |
| 266 | ||
| 267 | impl Registry { | |
| 268 | /// The token, else the password: what a registry that takes one | |
| 269 | /// secret is given. | |
| 270 | fn secret(&self) -> Option<&str> { | |
| 271 | self.token.as_deref().or(self.password.as_deref()).filter(|secret| !secret.is_empty()) | |
| 272 | } | |
| 273 | } | |
| 274 | ||
| 275 | /// What to raise, to what, where, and how. | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 276 | #[derive(Debug)] |
| 277 | struct Bump { | |
| 278 | ecosystem: Ecosystem, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 279 | kind: Kind, |
| 280 | packages: Vec<Target>, | |
| 281 | /// A version update's strategy; none for a security update, which | |
| 282 | /// always updates as it did before strategies. | |
| 283 | strategy: Option<Strategy>, | |
| 284 | /// Replace the branch if it is there already. | |
| 285 | force: bool, | |
| 286 | registries: Vec<Registry>, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 287 | jobs: Vec<Job>, |
| 288 | } | |
| 289 | ||
| 290 | /// One directory's lockfiles of one kind, updated by one tool run. | |
| 291 | #[derive(Debug, PartialEq, Eq)] | |
| 292 | struct Job { | |
| 293 | /// From the repository's root; empty for the root. | |
| 294 | dir: String, | |
| 295 | lockfile: Lockfile, | |
| 296 | /// The lockfiles' paths from the root, each read again afterwards. | |
| 297 | paths: Vec<String>, | |
| 298 | } | |
| 299 | ||
| 300 | impl Job { | |
| 301 | fn file(&self, name: &str) -> String { | |
| 302 | if self.dir.is_empty() { name.to_owned() } else { format!("{}/{name}", self.dir) } | |
| 303 | } | |
| 304 | ||
| 305 | /// What the tool may change: the lockfiles and their manifest. Only | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 306 | /// these, and the `Cargo.toml` files a version update raises a |
| 307 | /// requirement in, are committed, whatever else a tool leaves behind. | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 308 | fn touched(&self) -> Vec<String> { |
| 309 | let mut files: Vec<String> = self.paths.clone(); | |
| 310 | let manifests: &[&str] = match self.lockfile { | |
| 311 | Lockfile::PackageLock | Lockfile::PnpmLock | Lockfile::YarnLock => &["package.json"], | |
| 312 | Lockfile::GoMod | Lockfile::GoSum => &["go.mod", "go.sum"], | |
| 313 | Lockfile::PoetryLock => &["pyproject.toml"], | |
| 314 | Lockfile::CargoLock | Lockfile::Requirements => &[], | |
| 315 | }; | |
| 316 | files.extend(manifests.iter().map(|name| self.file(name))); | |
| 317 | files.sort(); | |
| 318 | files.dedup(); | |
| 319 | files | |
| 320 | } | |
| 321 | } | |
| 322 | ||
| 323 | /// `BUMP_LOCKFILES`: a JSON array, or one path per line. | |
| 324 | fn lockfile_list(text: &str) -> Result<Vec<String>> { | |
| 325 | let text = text.trim(); | |
| 326 | let paths: Vec<String> = if text.starts_with('[') { | |
| 327 | serde_json::from_str(text).context("BUMP_LOCKFILES is not a JSON array of paths")? | |
| 328 | } else { | |
| 329 | text.lines().map(str::to_owned).collect() | |
| 330 | }; | |
| 331 | Ok(paths.into_iter().map(|path| path.trim().trim_start_matches("./").to_owned()).filter(|path| !path.is_empty()).collect()) | |
| 332 | } | |
| 333 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 334 | /// The packages to raise: `BUMP_PACKAGES`, or `BUMP_PACKAGE` and |
| 335 | /// `BUMP_VERSION` when it is absent or empty. Each is checked as a tool | |
| 336 | /// argument, its version written as the tools take it, and a package named | |
| 337 | /// twice is raised once. | |
| 338 | fn targets(ecosystem: Ecosystem, listed: Option<&str>, package: Option<&str>, version: Option<&str>) -> Result<Vec<Target>> { | |
| 339 | let listed: Vec<Target> = match listed.map(str::trim).filter(|text| !text.is_empty()) { | |
| 340 | Some(text) => serde_json::from_str(text).context("BUMP_PACKAGES is not a JSON array of packages and versions")?, | |
| 341 | None => Vec::new(), | |
| 342 | }; | |
| 343 | let listed = if listed.is_empty() { | |
| 344 | vec![Target { | |
| 345 | package: package.ok_or_else(|| anyhow!("BUMP_PACKAGE is not set"))?.to_owned(), | |
| 346 | version: version.ok_or_else(|| anyhow!("BUMP_VERSION is not set"))?.to_owned(), | |
| 347 | }] | |
| 348 | } else { | |
| 349 | listed | |
| 350 | }; | |
| 351 | if listed.len() > MAX_PACKAGES { | |
| 352 | bail!("an update raises at most {MAX_PACKAGES} packages"); | |
| 353 | } | |
| 354 | let mut out: Vec<Target> = Vec::new(); | |
| 355 | for target in listed { | |
| 356 | let target = Target { package: target.package.trim().to_owned(), version: tool_version(ecosystem, &target.version) }; | |
| 357 | safe_argument("package", &target.package)?; | |
| 358 | safe_argument("version", &target.version)?; | |
| 359 | if !out.iter().any(|seen| ecosystem.normalize(&seen.package) == ecosystem.normalize(&target.package)) { | |
| 360 | out.push(target); | |
| 361 | } | |
| 362 | } | |
| 363 | Ok(out) | |
| 364 | } | |
| 365 | ||
| 366 | /// Whether git takes `branch` as a branch's name, short of a full ref. | |
| 367 | /// Mirrors `isBranchName` in services/runner bump.ts. | |
| 368 | fn branch_name_ok(branch: &str) -> bool { | |
| 369 | let bad = |c: char| c.is_whitespace() || c.is_control() || "~^:?*[\\".contains(c); | |
| 370 | !branch.trim().is_empty() | |
| 371 | && branch.len() <= 200 | |
| 372 | && !branch.chars().any(bad) | |
| 373 | && !branch.contains("..") | |
| 374 | && !branch.contains("@{") | |
| 375 | && !branch.starts_with('-') | |
| 376 | && !branch.starts_with('/') | |
| 377 | && !branch.starts_with("refs/") | |
| 378 | && !branch.ends_with('/') | |
| 379 | && !branch.ends_with(".lock") | |
| 380 | } | |
| 381 | ||
| 382 | /// Whether this kind of update may push to `branch`: a security update's | |
| 383 | /// is under `g1t/security/`; a version update's is any name git takes but | |
| 384 | /// `base`, the default branch. | |
| 385 | fn check_branch(kind: Kind, branch: &str, base: &str) -> Result<()> { | |
| 386 | match kind { | |
| 387 | Kind::Security => { | |
| 388 | if !branch.starts_with(BRANCH_PREFIX) || branch.contains("..") || branch.chars().any(char::is_whitespace) { | |
| 389 | bail!("{branch} is not a security update's branch"); | |
| 390 | } | |
| 391 | } | |
| 392 | Kind::Version => { | |
| 393 | if !branch_name_ok(branch) { | |
| 394 | bail!("{branch:?} is not a branch name git takes"); | |
| 395 | } | |
| 396 | if branch == base.trim() { | |
| 397 | bail!("a version update cannot push to {base}, the default branch"); | |
| 398 | } | |
| 399 | } | |
| 400 | } | |
| 401 | Ok(()) | |
| 402 | } | |
| 403 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 404 | /// The lockfiles grouped into what one tool run updates: `go.mod` and |
| 405 | /// `go.sum` in one directory are one module. Each must be a lockfile of | |
| 406 | /// `ecosystem`, inside the repository. | |
| 407 | fn jobs(ecosystem: Ecosystem, paths: &[String]) -> Result<Vec<Job>> { | |
| 408 | let mut jobs: Vec<Job> = Vec::new(); | |
| 409 | for path in paths { | |
| 410 | if path.starts_with('/') || path.contains('\\') || path.split('/').any(|part| part == ".." || part.is_empty()) { | |
| 411 | bail!("{path} is not a path inside the repository"); | |
| 412 | } | |
| 413 | let lockfile = Lockfile::for_path(path).ok_or_else(|| unsupported(format!("{path} is not a lockfile g1t can update")))?; | |
| 414 | if lockfile.ecosystem() != ecosystem { | |
| 415 | bail!("{path} is not a {} lockfile", ecosystem.osv()); | |
| 416 | } | |
| 417 | let dir = path.rsplit_once('/').map(|(dir, _)| dir.to_owned()).unwrap_or_default(); | |
| 418 | let lockfile = if lockfile == Lockfile::GoSum { Lockfile::GoMod } else { lockfile }; | |
| 419 | match jobs.iter_mut().find(|job| job.dir == dir && job.lockfile == lockfile) { | |
| 420 | Some(job) => { | |
| 421 | if !job.paths.contains(path) { | |
| 422 | job.paths.push(path.clone()); | |
| 423 | } | |
| 424 | } | |
| 425 | None => jobs.push(Job { dir, lockfile, paths: vec![path.clone()] }), | |
| 426 | } | |
| 427 | } | |
| 428 | if jobs.is_empty() { | |
| 429 | bail!("BUMP_LOCKFILES names no lockfile"); | |
| 430 | } | |
| 431 | Ok(jobs) | |
| 432 | } | |
| 433 | ||
| 434 | /// A version as the ecosystem's tools take it: Go's with a leading `v`, | |
| 435 | /// everyone else's without. | |
| 436 | fn tool_version(ecosystem: Ecosystem, version: &str) -> String { | |
| 437 | let version = version.trim(); | |
| 438 | let bare = match version.strip_prefix(['v', 'V']) { | |
| 439 | Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest, | |
| 440 | _ => version, | |
| 441 | }; | |
| 442 | match ecosystem { | |
| 443 | Ecosystem::Go => format!("v{bare}"), | |
| 444 | _ => bare.to_owned(), | |
| 445 | } | |
| 446 | } | |
| 447 | ||
| 448 | /// A package name or version is passed to tools as one argument: it must | |
| 449 | /// not read as an option, and holds only what names and versions do. | |
| 450 | fn safe_argument(what: &str, text: &str) -> Result<()> { | |
| 451 | let allowed = |c: char| c.is_ascii_alphanumeric() || "@/._-+~".contains(c); | |
| 452 | if text.is_empty() || text.starts_with('-') || !text.chars().all(allowed) || text.len() > 214 { | |
| 453 | bail!("{what} {text:?} is not a package name or version g1t can pass to a tool"); | |
| 454 | } | |
| 455 | Ok(()) | |
| 456 | } | |
| 457 | ||
| 458 | /// The range to ask for a direct dependency now at `current`: the same | |
| 459 | /// style (`^1.2.3`, `~1.2.3`, exact), and `^` for any other. | |
| 460 | fn raised_range(current: &str, version: &str) -> String { | |
| 461 | let current = current.trim(); | |
| 462 | if current.starts_with('~') { | |
| 463 | format!("~{version}") | |
| 464 | } else if current.starts_with(|c: char| c.is_ascii_digit()) || current.starts_with('=') { | |
| 465 | version.to_owned() | |
| 466 | } else { | |
| 467 | format!("^{version}") | |
| 468 | } | |
| 469 | } | |
| 470 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 471 | /// The range `widen` asks for: what `current` allowed, or the raised range. |
| 472 | fn widened_range(current: &str, version: &str) -> String { | |
| 473 | format!("{} || {}", current.trim(), raised_range(current, version)) | |
| 474 | } | |
| 475 | ||
| 476 | /// How a direct JavaScript dependency is raised: first the update its | |
| 477 | /// range allows, or not, then the range to ask for if still below, if any. | |
| 478 | #[derive(Debug, PartialEq, Eq)] | |
| 479 | struct DirectPlan { | |
| 480 | in_range_first: bool, | |
| 481 | range: Option<String>, | |
| 482 | } | |
| 483 | ||
| 484 | fn direct_plan(strategy: Option<Strategy>, current: &str, version: &str) -> DirectPlan { | |
| 485 | match strategy { | |
| 486 | None | Some(Strategy::Increase) => DirectPlan { in_range_first: false, range: Some(raised_range(current, version)) }, | |
| 487 | Some(Strategy::IncreaseIfNecessary) => DirectPlan { in_range_first: true, range: Some(raised_range(current, version)) }, | |
| 488 | Some(Strategy::Widen) => DirectPlan { in_range_first: true, range: Some(widened_range(current, version)) }, | |
| 489 | Some(Strategy::LockfileOnly) => DirectPlan { in_range_first: true, range: None }, | |
| 490 | } | |
| 491 | } | |
| 492 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 493 | /// The range `package.json` asks for `package` with, if it is a direct |
| 494 | /// dependency from the registry (not a workspace, link, alias or URL). | |
| 495 | fn direct_range(manifest: &Value, package: &str) -> Option<String> { | |
| 496 | ["dependencies", "devDependencies", "optionalDependencies"].iter().find_map(|section| { | |
| 497 | let range = manifest.get(section)?.get(package)?.as_str()?; | |
| 498 | let registry = !range.contains(':') && !range.contains('/'); | |
| 499 | registry.then(|| range.to_owned()) | |
| 500 | }) | |
| 501 | } | |
| 502 | ||
| 503 | /// Which JavaScript package manager wrote a lockfile. | |
| 504 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 505 | enum Node { | |
| 506 | Npm, | |
| 507 | Pnpm, | |
| 508 | /// Yarn 1. | |
| 509 | YarnClassic, | |
| 510 | /// Yarn 2 and later, whose lockfile has `__metadata`. | |
| 511 | YarnBerry, | |
| 512 | } | |
| 513 | ||
| 514 | impl Node { | |
| 515 | fn of(lockfile: Lockfile, text: &str) -> Option<Node> { | |
| 516 | Some(match lockfile { | |
| 517 | Lockfile::PackageLock => Node::Npm, | |
| 518 | Lockfile::PnpmLock => Node::Pnpm, | |
| 519 | Lockfile::YarnLock if text.lines().any(|line| line.starts_with("__metadata:")) => Node::YarnBerry, | |
| 520 | Lockfile::YarnLock => Node::YarnClassic, | |
| 521 | _ => return None, | |
| 522 | }) | |
| 523 | } | |
| 524 | ||
| 525 | /// The command, through corepack for pnpm and yarn, so the version the | |
| 526 | /// project's `packageManager` names is the one that runs. | |
| 527 | fn command(self, args: &[&str]) -> Vec<String> { | |
| 528 | let mut command: Vec<&str> = match self { | |
| 529 | Node::Npm => vec!["npm"], | |
| 530 | Node::Pnpm => vec!["corepack", "pnpm"], | |
| 531 | Node::YarnClassic | Node::YarnBerry => vec!["corepack", "yarn"], | |
| 532 | }; | |
| 533 | command.extend(args); | |
| 534 | command.into_iter().map(str::to_owned).collect() | |
| 535 | } | |
| 536 | ||
| 537 | /// Raises a direct dependency to `range`. | |
| 538 | fn direct(self, package: &str, range: &str) -> Vec<String> { | |
| 539 | let spec = format!("{package}@{range}"); | |
| 540 | match self { | |
| 541 | Node::Npm => self.command(&["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", &spec]), | |
| 542 | Node::Pnpm => self.command(&["update", &spec, "--lockfile-only", "--ignore-scripts"]), | |
| 543 | Node::YarnBerry => self.command(&["up", &spec, "--mode=update-lockfile"]), | |
| 544 | Node::YarnClassic => self.command(&["upgrade", &spec, "--ignore-scripts", "--non-interactive"]), | |
| 545 | } | |
| 546 | } | |
| 547 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 548 | /// Moves a direct dependency as far as its range allows, leaving the |
| 549 | /// range in `package.json` as it is. | |
| 550 | fn in_range(self, package: &str) -> Vec<String> { | |
| 551 | match self { | |
| 552 | Node::Npm => self.command(&["update", "--package-lock-only", "--no-save", "--ignore-scripts", "--no-audit", "--no-fund", package]), | |
| 553 | Node::Pnpm => self.command(&["update", package, "--lockfile-only", "--no-save", "--ignore-scripts"]), | |
| 554 | Node::YarnBerry => self.command(&["up", "--recursive", package, "--mode=update-lockfile"]), | |
| 555 | Node::YarnClassic => self.command(&["upgrade", package, "--ignore-scripts", "--non-interactive"]), | |
| 556 | } | |
| 557 | } | |
| 558 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 559 | /// Moves a package something else depends on as far as the ranges |
| 560 | /// that ask for it allow. Yarn 1 has no such command. | |
| 561 | fn transitive(self, package: &str) -> Option<Vec<String>> { | |
| 562 | Some(match self { | |
| 563 | Node::Npm => self.command(&["update", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", package]), | |
| 564 | Node::Pnpm => self.command(&["update", package, "--depth", "Infinity", "--lockfile-only", "--ignore-scripts"]), | |
| 565 | Node::YarnBerry => self.command(&["up", "--recursive", package, "--mode=update-lockfile"]), | |
| 566 | Node::YarnClassic => return None, | |
| 567 | }) | |
| 568 | } | |
| 569 | ||
| 570 | /// Where `package.json` forces a version on everything that asks for | |
| 571 | /// a package. | |
| 572 | fn override_path(self) -> &'static [&'static str] { | |
| 573 | match self { | |
| 574 | Node::Npm => &["overrides"], | |
| 575 | Node::Pnpm => &["pnpm", "overrides"], | |
| 576 | Node::YarnClassic | Node::YarnBerry => &["resolutions"], | |
| 577 | } | |
| 578 | } | |
| 579 | ||
| 580 | /// Writes the lockfile again from `package.json`. | |
| 581 | fn relock(self) -> Vec<String> { | |
| 582 | match self { | |
| 583 | Node::Npm => self.command(&["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"]), | |
| 584 | Node::Pnpm => self.command(&["install", "--lockfile-only", "--ignore-scripts"]), | |
| 585 | Node::YarnBerry => self.command(&["install", "--mode=update-lockfile"]), | |
| 586 | Node::YarnClassic => self.command(&["install", "--ignore-scripts", "--non-interactive"]), | |
| 587 | } | |
| 588 | } | |
| 589 | } | |
| 590 | ||
| 591 | /// Adds `package: version` to the overrides at `path` in `package.json`, | |
| 592 | /// creating the objects on the way. Returns false when it is already there. | |
| 593 | fn add_override(manifest: &mut Value, path: &[&str], package: &str, version: &str) -> Result<bool> { | |
| 594 | let mut at = manifest; | |
| 595 | for key in path { | |
| 596 | let object = at.as_object_mut().ok_or_else(|| anyhow!("package.json is not an object"))?; | |
| 597 | at = object.entry(key.to_string()).or_insert_with(|| json!({})); | |
| 598 | } | |
| 599 | let object = at.as_object_mut().ok_or_else(|| anyhow!("package.json's {} is not an object", path.join(".")))?; | |
| 600 | if object.get(package).and_then(Value::as_str) == Some(version) { | |
| 601 | return Ok(false); | |
| 602 | } | |
| 603 | object.insert(package.to_owned(), Value::String(version.to_owned())); | |
| 604 | Ok(true) | |
| 605 | } | |
| 606 | ||
| 607 | /// What Cargo runs for each locked version of `package` below `version`: | |
| 608 | /// straight to it, or, when that is past what a dependent's requirement | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 609 | /// allows, as far as the requirement does. `config` is a configuration |
| 610 | /// file every cargo run is given (a private registry's source replacement). | |
| 611 | fn cargo_commands(package: &str, old: &str, version: &str, config: Option<&str>) -> [Vec<String>; 2] { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 612 | let spec = format!("{package}@{old}"); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 613 | let cargo = || -> Vec<String> { |
| 614 | let mut command = vec!["cargo".to_owned()]; | |
| 615 | if let Some(config) = config { | |
| 616 | command.extend(["--config".to_owned(), config.to_owned()]); | |
| 617 | } | |
| 618 | command | |
| 619 | }; | |
| 620 | let mut precise = cargo(); | |
| 621 | precise.extend(["update", "-p", &spec, "--precise", version].map(str::to_owned)); | |
| 622 | let mut compatible = cargo(); | |
| 623 | compatible.extend(["update", "-p", &spec].map(str::to_owned)); | |
| 624 | [precise, compatible] | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 625 | } |
| 626 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 627 | /// A Cargo requirement raised to `version`, keeping its operator (`^`, `~`, |
| 628 | /// `=`, `>=` or none), or `None` when it is not a single requirement below | |
| 629 | /// `version`. | |
| 630 | fn raised_requirement(requirement: &str, version: &str) -> Option<String> { | |
| 631 | let requirement = requirement.trim(); | |
| 632 | let (operator, rest) = [">=", "^", "~", "="] | |
| 633 | .iter() | |
| 634 | .find_map(|operator| requirement.strip_prefix(operator).map(|rest| (*operator, rest))) | |
| 635 | .unwrap_or(("", requirement)); | |
| 636 | let rest = rest.trim(); | |
| 637 | let plain = !rest.is_empty() && rest.starts_with(|c: char| c.is_ascii_digit()) && rest.chars().all(|c| c.is_ascii_alphanumeric() || ".-+".contains(c)); | |
| 638 | (plain && version::compare(rest, version).is_lt()).then(|| format!("{operator}{version}")) | |
| 639 | } | |
| 640 | ||
| 641 | /// The dotted keys of a TOML table header (`[target.'cfg(unix)'.dependencies]`), | |
| 642 | /// unquoted; `None` for an array of tables or a line that is not a header. | |
| 643 | fn header_keys(line: &str) -> Option<Vec<String>> { | |
| 644 | let code = line.trim_start(); | |
| 645 | if code.starts_with("[[") || !code.starts_with('[') { | |
| 646 | return None; | |
| 647 | } | |
| 648 | let mut keys = Vec::new(); | |
| 649 | let mut key = String::new(); | |
| 650 | let mut quote: Option<char> = None; | |
| 651 | for c in code[1..].chars() { | |
| 652 | match (quote, c) { | |
| 653 | (Some(q), c) if c == q => quote = None, | |
| 654 | (Some(_), c) => key.push(c), | |
| 655 | (None, '"' | '\'') => quote = Some(c), | |
| 656 | (None, '.') => keys.push(std::mem::take(&mut key).trim().to_owned()), | |
| 657 | (None, ']') => { | |
| 658 | keys.push(key.trim().to_owned()); | |
| 659 | return Some(keys); | |
| 660 | } | |
| 661 | (None, c) => key.push(c), | |
| 662 | } | |
| 663 | } | |
| 664 | None | |
| 665 | } | |
| 666 | ||
| 667 | /// What a table header is to dependencies: `Some(None)` for a table of | |
| 668 | /// them (`[dependencies]`, `[workspace.dependencies]`, | |
| 669 | /// `[target.<cfg>.dev-dependencies]` …), `Some(Some(name))` for one | |
| 670 | /// dependency's own table (`[dependencies.serde]`), `None` for anything else. | |
| 671 | fn dependency_table(keys: &[String]) -> Option<Option<String>> { | |
| 672 | let tables = ["dependencies", "dev-dependencies", "build-dependencies"]; | |
| 673 | let rest = match keys { | |
| 674 | [first, rest @ ..] if tables.contains(&first.as_str()) => rest, | |
| 675 | [workspace, dependencies, rest @ ..] if workspace == "workspace" && dependencies == "dependencies" => rest, | |
| 676 | [target, _, table, rest @ ..] if target == "target" && tables.contains(&table.as_str()) => rest, | |
| 677 | _ => return None, | |
| 678 | }; | |
| 679 | match rest { | |
| 680 | [] => Some(None), | |
| 681 | [name] => Some(Some(name.clone())), | |
| 682 | _ => None, | |
| 683 | } | |
| 684 | } | |
| 685 | ||
| 686 | /// Where the value of `key` starts in a one-line TOML `line`: just after | |
| 687 | /// its `=`, if `key` is a bare key there (first, or after `{` or `,`). | |
| 688 | fn value_after(line: &str, key: &str) -> Option<usize> { | |
| 689 | let mut from = 0; | |
| 690 | while let Some(at) = line[from..].find(key).map(|at| from + at) { | |
| 691 | let before = line[..at].trim_end(); | |
| 692 | let after = line[at + key.len()..].trim_start(); | |
| 693 | let bare_key = before.is_empty() || before.ends_with('{') || before.ends_with(','); | |
| 694 | if bare_key && after.starts_with('=') { | |
| 695 | let equals = line.len() - after.len(); | |
| 696 | return Some(equals + 1); | |
| 697 | } | |
| 698 | from = at + key.len(); | |
| 699 | } | |
| 700 | None | |
| 701 | } | |
| 702 | ||
| 703 | /// `line` with the first quoted `old` from `from` on replaced by `new`. | |
| 704 | fn replace_quoted(line: &str, from: usize, old: &str, new: &str) -> Option<String> { | |
| 705 | ['"', '\''].iter().find_map(|quote| { | |
| 706 | let quoted = format!("{quote}{old}{quote}"); | |
| 707 | let at = from + line[from..].find("ed)?; | |
| 708 | Some(format!("{}{quote}{new}{quote}{}", &line[..at], &line[at + quoted.len()..])) | |
| 709 | }) | |
| 710 | } | |
| 711 | ||
| 712 | /// One `key = value` line of a dependency table, rewritten when it asks | |
| 713 | /// for `package` with a requirement below `version`. | |
| 714 | fn rewrite_dependency_line(line: &str, package: &str, version: &str) -> Option<String> { | |
| 715 | let table: toml::Table = toml::from_str(line).ok()?; | |
| 716 | let (key, value) = table.iter().next()?; | |
| 717 | let (name, requirement, anchor) = match value { | |
| 718 | toml::Value::String(requirement) => (key.as_str(), requirement.as_str(), line.find('=')? + 1), | |
| 719 | toml::Value::Table(inline) => { | |
| 720 | let name = inline.get("package").and_then(toml::Value::as_str).unwrap_or(key); | |
| 721 | let requirement = inline.get("version").and_then(toml::Value::as_str)?; | |
| 722 | (name, requirement, value_after(line, "version")?) | |
| 723 | } | |
| 724 | _ => return None, | |
| 725 | }; | |
| 726 | if name != package { | |
| 727 | return None; | |
| 728 | } | |
| 729 | replace_quoted(line, anchor, requirement, &raised_requirement(requirement, version)?) | |
| 730 | } | |
| 731 | ||
| 732 | /// A `Cargo.toml` with every requirement on `package` below `version` | |
| 733 | /// raised to it, keeping its operator, and nothing else changed. Returns | |
| 734 | /// the text and how many requirements moved. | |
| 735 | fn rewrite_cargo_requirements(text: &str, package: &str, version: &str) -> (String, usize) { | |
| 736 | // Each section: its header's meaning to dependencies, and its lines. | |
| 737 | let mut sections: Vec<(Option<Option<String>>, Vec<String>)> = vec![(None, Vec::new())]; | |
| 738 | for line in text.split_inclusive('\n') { | |
| 739 | if let Some(keys) = header_keys(line) { | |
| 740 | sections.push((dependency_table(&keys), Vec::new())); | |
| 741 | } else if line.trim_start().starts_with("[[") { | |
| 742 | sections.push((None, Vec::new())); | |
| 743 | } | |
| 744 | sections.last_mut().expect("a section").1.push(line.to_owned()); | |
| 745 | } | |
| 746 | let mut moved = 0; | |
| 747 | let mut out = String::with_capacity(text.len() + 8); | |
| 748 | for (table, mut lines) in sections { | |
| 749 | match table { | |
| 750 | Some(None) => { | |
| 751 | for line in lines.iter_mut().skip(1) { | |
| 752 | if let Some(rewritten) = rewrite_dependency_line(line, package, version) { | |
| 753 | *line = rewritten; | |
| 754 | moved += 1; | |
| 755 | } | |
| 756 | } | |
| 757 | } | |
| 758 | Some(Some(key)) => { | |
| 759 | let field = |name: &str| { | |
| 760 | lines.iter().enumerate().skip(1).find_map(|(at, line)| { | |
| 761 | let table: toml::Table = toml::from_str(line).ok()?; | |
| 762 | Some((at, table.get(name)?.as_str()?.to_owned())) | |
| 763 | }) | |
| 764 | }; | |
| 765 | let name = field("package").map_or(key, |(_, name)| name); | |
| 766 | if let (true, Some((at, requirement))) = (name == package, field("version")) { | |
| 767 | let rewritten = raised_requirement(&requirement, version).and_then(|raised| { | |
| 768 | let line = &lines[at]; | |
| 769 | replace_quoted(line, line.find('=')? + 1, &requirement, &raised) | |
| 770 | }); | |
| 771 | if let Some(rewritten) = rewritten { | |
| 772 | lines[at] = rewritten; | |
| 773 | moved += 1; | |
| 774 | } | |
| 775 | } | |
| 776 | } | |
| 777 | None => {} | |
| 778 | } | |
| 779 | lines.iter().for_each(|line| out.push_str(line)); | |
| 780 | } | |
| 781 | (out, moved) | |
| 782 | } | |
| 783 | ||
| 784 | /// The workspace members a root `Cargo.toml` lists, as written: paths, and | |
| 785 | /// paths ending in `/*`. Other globs and paths outside it are left out. | |
| 786 | fn workspace_members(root: &toml::Value) -> Vec<String> { | |
| 787 | let Some(members) = root.get("workspace").and_then(|workspace| workspace.get("members")).and_then(toml::Value::as_array) else { | |
| 788 | return Vec::new(); | |
| 789 | }; | |
| 790 | members | |
| 791 | .iter() | |
| 792 | .filter_map(toml::Value::as_str) | |
| 793 | .map(|member| member.trim().trim_start_matches("./").trim_end_matches('/').to_owned()) | |
| 794 | .filter(|member| { | |
| 795 | let globbed = member.strip_suffix("/*").unwrap_or(member); | |
| 796 | !member.is_empty() && !globbed.contains(['*', '?', '[']) && !member.starts_with('/') && !member.split('/').any(|part| part == "..") | |
| 797 | }) | |
| 798 | .collect() | |
| 799 | } | |
| 800 | ||
| 801 | /// The `Cargo.toml` files of a Cargo job, from the repository's root: its | |
| 802 | /// directory's and its workspace members'. | |
| 803 | fn cargo_manifests(workdir: &Path, job: &Job) -> Vec<String> { | |
| 804 | let root = job.file("Cargo.toml"); | |
| 805 | let mut found = vec![root.clone()]; | |
| 806 | let Ok(text) = std::fs::read_to_string(workdir.join(&root)) else { | |
| 807 | return found; | |
| 808 | }; | |
| 809 | let Ok(parsed) = toml::from_str::<toml::Value>(&text) else { | |
| 810 | return found; | |
| 811 | }; | |
| 812 | for member in workspace_members(&parsed) { | |
| 813 | let dirs: Vec<String> = match member.strip_suffix("/*") { | |
| 814 | Some(parent) => { | |
| 815 | let parent = job.file(parent); | |
| 816 | let mut names: Vec<String> = std::fs::read_dir(workdir.join(&parent)) | |
| 817 | .map(|entries| { | |
| 818 | entries | |
| 819 | .filter_map(|entry| entry.ok()) | |
| 820 | .filter(|entry| entry.path().is_dir()) | |
| 821 | .filter_map(|entry| entry.file_name().into_string().ok()) | |
| 822 | .map(|name| format!("{parent}/{name}")) | |
| 823 | .collect() | |
| 824 | }) | |
| 825 | .unwrap_or_default(); | |
| 826 | names.sort(); | |
| 827 | names | |
| 828 | } | |
| 829 | None => vec![job.file(&member)], | |
| 830 | }; | |
| 831 | for dir in dirs { | |
| 832 | let manifest = format!("{dir}/Cargo.toml"); | |
| 833 | if workdir.join(&manifest).is_file() && !found.contains(&manifest) { | |
| 834 | found.push(manifest); | |
| 835 | } | |
| 836 | } | |
| 837 | } | |
| 838 | found | |
| 839 | } | |
| 840 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 841 | fn go_commands(module: &str, version: &str) -> [Vec<String>; 2] { |
| 842 | [ | |
| 843 | vec!["go".into(), "get".into(), format!("{module}@{version}")], | |
| 844 | ["go", "mod", "tidy"].map(str::to_owned).to_vec(), | |
| 845 | ] | |
| 846 | } | |
| 847 | ||
| 848 | /// Which dependency group of `pyproject.toml` names `package`: `Some(None)` | |
| 849 | /// for the main one, `Some(Some(group))` for another, `None` when it is not | |
| 850 | /// a direct dependency. | |
| 851 | fn poetry_group(pyproject: &toml::Value, package: &str) -> Option<Option<String>> { | |
| 852 | let wanted = Ecosystem::PyPI.normalize(package); | |
| 853 | let names = |table: Option<&toml::Value>| -> bool { | |
| 854 | table | |
| 855 | .and_then(toml::Value::as_table) | |
| 856 | .is_some_and(|table| table.keys().any(|key| Ecosystem::PyPI.normalize(key) == wanted)) | |
| 857 | }; | |
| 858 | let poetry = pyproject.get("tool").and_then(|tool| tool.get("poetry")); | |
| 859 | if names(poetry.and_then(|poetry| poetry.get("dependencies"))) { | |
| 860 | return Some(None); | |
| 861 | } | |
| 862 | let pep621 = pyproject | |
| 863 | .get("project") | |
| 864 | .and_then(|project| project.get("dependencies")) | |
| 865 | .and_then(toml::Value::as_array) | |
| 866 | .is_some_and(|list| { | |
| 867 | list.iter().filter_map(toml::Value::as_str).any(|requirement| { | |
| 868 | let name: String = requirement.chars().take_while(|c| c.is_ascii_alphanumeric() || "-_.".contains(*c)).collect(); | |
| 869 | Ecosystem::PyPI.normalize(&name) == wanted | |
| 870 | }) | |
| 871 | }); | |
| 872 | if pep621 { | |
| 873 | return Some(None); | |
| 874 | } | |
| 875 | if names(poetry.and_then(|poetry| poetry.get("dev-dependencies"))) { | |
| 876 | return Some(Some("dev".to_owned())); | |
| 877 | } | |
| 878 | let groups = poetry.and_then(|poetry| poetry.get("group")).and_then(toml::Value::as_table)?; | |
| 879 | groups | |
| 880 | .iter() | |
| 881 | .find(|(_, group)| names(group.get("dependencies"))) | |
| 882 | .map(|(name, _)| Some(name.clone())) | |
| 883 | } | |
| 884 | ||
| 885 | fn poetry_commands(poetry: &[String], package: &str, version: &str, group: Option<Option<String>>) -> Vec<String> { | |
| 886 | let mut command = poetry.to_vec(); | |
| 887 | match group { | |
| 888 | Some(group) => { | |
| 889 | command.extend(["add".to_owned(), format!("{package}@^{version}"), "--lock".to_owned()]); | |
| 890 | if let Some(group) = group { | |
| 891 | command.extend(["--group".to_owned(), group]); | |
| 892 | } | |
| 893 | } | |
| 894 | None => command.extend(["update".to_owned(), "--lock".to_owned(), package.to_owned()]), | |
| 895 | } | |
| 896 | command | |
| 897 | } | |
| 898 | ||
| 899 | /// `requirements.txt` with every `==` (or `===`) pin of `package` below | |
| 900 | /// `version` raised to it, and nothing else changed. Returns the text and | |
| 901 | /// how many pins moved. | |
| 902 | fn rewrite_pins(text: &str, package: &str, version: &str) -> (String, usize) { | |
| 903 | let wanted = Ecosystem::PyPI.normalize(package); | |
| 904 | let mut moved = 0; | |
| 905 | let mut out = String::with_capacity(text.len() + 8); | |
| 906 | for line in text.split_inclusive('\n') { | |
| 907 | let rewritten = (|| { | |
| 908 | let code = line.split('#').next().unwrap_or_default(); | |
| 909 | let trimmed = code.trim_start(); | |
| 910 | if trimmed.starts_with('-') || code.contains("://") { | |
| 911 | return None; | |
| 912 | } | |
| 913 | let operator = code.find("===").map(|at| (at, 3)).or_else(|| code.find("==").map(|at| (at, 2)))?; | |
| 914 | let name = code[..operator.0].split('[').next().unwrap_or_default().trim(); | |
| 915 | if Ecosystem::PyPI.normalize(name) != wanted { | |
| 916 | return None; | |
| 917 | } | |
| 918 | let start = operator.0 + operator.1; | |
| 919 | let rest = &code[start..]; | |
| 920 | let leading = rest.len() - rest.trim_start().len(); | |
| 921 | let from = start + leading; | |
| 922 | let end = code[from..] | |
| 923 | .find(|c: char| c.is_whitespace() || ",;\\".contains(c)) | |
| 924 | .map_or(code.len(), |at| from + at); | |
| 925 | let old = &line[from..end]; | |
| 926 | if old.is_empty() || old.contains('*') || version::compare(old, version).is_ge() { | |
| 927 | return None; | |
| 928 | } | |
| 929 | Some(format!("{}{version}{}", &line[..from], &line[end..])) | |
| 930 | })(); | |
| 931 | match rewritten { | |
| 932 | Some(line) => { | |
| 933 | moved += 1; | |
| 934 | out.push_str(&line); | |
| 935 | } | |
| 936 | None => out.push_str(line), | |
| 937 | } | |
| 938 | } | |
| 939 | (out, moved) | |
| 940 | } | |
| 941 | ||
| 942 | /// The versions of `package` a lockfile still resolves below `version`. | |
| 943 | fn below(lockfile: Lockfile, text: &str, ecosystem: Ecosystem, package: &str, version: &str) -> Vec<String> { | |
| 944 | let name = ecosystem.normalize(package); | |
| 945 | let found: BTreeSet<String> = lockfile | |
| 946 | .parse(text) | |
| 947 | .into_iter() | |
| 948 | .filter(|found| found.name == name && version::compare(&found.version, version).is_lt()) | |
| 949 | .map(|found| found.version) | |
| 950 | .collect(); | |
| 951 | found.into_iter().collect() | |
| 952 | } | |
| 953 | ||
| 954 | /// The last lines of what a tool said, for the reason it failed. | |
| 955 | fn tail(text: &str, lines: usize) -> String { | |
| 956 | let all: Vec<&str> = text.lines().filter(|line| !line.trim().is_empty()).collect(); | |
| 957 | all[all.len().saturating_sub(lines)..].join("\n") | |
| 958 | } | |
| 959 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 960 | /// The registries a project names itself, so their credentials can be |
| 961 | /// given under its names: Cargo's `[registries.<name>] index` and Poetry's | |
| 962 | /// `[[tool.poetry.source]]`, each as (name, URL). | |
| 963 | #[derive(Debug, Default, PartialEq, Eq)] | |
| 964 | struct Named { | |
| 965 | cargo: Vec<(String, String)>, | |
| 966 | poetry: Vec<(String, String)>, | |
| 967 | } | |
| 968 | ||
| 969 | /// `[registries.<name>] index = "…"` in a `.cargo/config.toml`. | |
| 970 | fn cargo_registries(config: &str) -> Vec<(String, String)> { | |
| 971 | let Ok(config) = toml::from_str::<toml::Value>(config) else { | |
| 972 | return Vec::new(); | |
| 973 | }; | |
| 974 | let Some(registries) = config.get("registries").and_then(toml::Value::as_table) else { | |
| 975 | return Vec::new(); | |
| 976 | }; | |
| 977 | registries | |
| 978 | .iter() | |
| 979 | .filter_map(|(name, registry)| Some((name.clone(), registry.get("index")?.as_str()?.to_owned()))) | |
| 980 | .collect() | |
| 981 | } | |
| 982 | ||
| 983 | /// `[[tool.poetry.source]]` names and URLs in a `pyproject.toml`. | |
| 984 | fn poetry_sources(pyproject: &str) -> Vec<(String, String)> { | |
| 985 | let Ok(pyproject) = toml::from_str::<toml::Value>(pyproject) else { | |
| 986 | return Vec::new(); | |
| 987 | }; | |
| 988 | let Some(sources) = pyproject.get("tool").and_then(|tool| tool.get("poetry")).and_then(|poetry| poetry.get("source")).and_then(toml::Value::as_array) else { | |
| 989 | return Vec::new(); | |
| 990 | }; | |
| 991 | sources | |
| 992 | .iter() | |
| 993 | .filter_map(|source| Some((source.get("name")?.as_str()?.to_owned(), source.get("url")?.as_str()?.to_owned()))) | |
| 994 | .collect() | |
| 995 | } | |
| 996 | ||
| 997 | /// A registry URL as compared with another: no index protocol, scheme or | |
| 998 | /// host case, or trailing slashes. | |
| 999 | fn same_registry(a: &str, b: &str) -> bool { | |
| 1000 | let plain = |url: &str| -> String { | |
| 1001 | let url = url.trim(); | |
| 1002 | let url = url.strip_prefix("sparse+").or_else(|| url.strip_prefix("registry+")).unwrap_or(url); | |
| 1003 | url.trim_end_matches('/').trim_end_matches(".git").to_ascii_lowercase() | |
| 1004 | }; | |
| 1005 | plain(a) == plain(b) | |
| 1006 | } | |
| 1007 | ||
| 1008 | /// A name as an environment variable's part: upper case, with `-` and `.` | |
| 1009 | /// as `_`. | |
| 1010 | fn env_part(name: &str) -> String { | |
| 1011 | name.to_ascii_uppercase().replace(['-', '.'], "_") | |
| 1012 | } | |
| 1013 | ||
| 1014 | /// Percent-encodes a URL's user or password. | |
| 1015 | fn percent_encode(text: &str) -> String { | |
| 1016 | text.bytes() | |
| 1017 | .map(|byte| match byte { | |
| 1018 | b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => (byte as char).to_string(), | |
| 1019 | _ => format!("%{byte:02X}"), | |
| 1020 | }) | |
| 1021 | .collect() | |
| 1022 | } | |
| 1023 | ||
| 1024 | /// `https://host/path` as npm keys its credentials: `//host/path/`. | |
| 1025 | fn npm_nerf_dart(url: &str) -> String { | |
| 1026 | let rest = url.trim().strip_prefix("https://").unwrap_or(url); | |
| 1027 | let rest = rest.trim_end_matches('/'); | |
| 1028 | format!("//{rest}/") | |
| 1029 | } | |
| 1030 | ||
| 1031 | /// A registry URL's host, for a netrc entry. | |
| 1032 | fn url_host(url: &str) -> &str { | |
| 1033 | let rest = url.trim().strip_prefix("https://").unwrap_or(url); | |
| 1034 | let host = rest.split(['/', '?', '#']).next().unwrap_or(rest); | |
| 1035 | host.rsplit('@').next().unwrap_or(host).split(':').next().unwrap_or(host) | |
| 1036 | } | |
| 1037 | ||
| 1038 | /// Checks one registry before anything is written: a kind this can | |
| 1039 | /// configure, an `https://` URL and text that cannot break out of a | |
| 1040 | /// configuration line. | |
| 1041 | fn check_registry(registry: &Registry) -> Result<()> { | |
| 1042 | let kinds = ["npm-registry", "cargo-registry", "python-index", "goproxy-server"]; | |
| 1043 | if !kinds.contains(®istry.kind.as_str()) { | |
| 1044 | bail!("g1t cannot read a {} registry", registry.kind); | |
| 1045 | } | |
| 1046 | let url = registry.url.trim(); | |
| 1047 | if !url.starts_with("https://") || url_host(url).is_empty() || url.chars().any(|c| c.is_whitespace() || c.is_control() || c == ',') { | |
| 1048 | bail!("a private registry's URL must start with https://, without spaces or commas"); | |
| 1049 | } | |
| 1050 | let secrets = [®istry.username, ®istry.password, ®istry.token]; | |
| 1051 | if secrets.iter().filter_map(|secret| secret.as_deref()).any(|secret| secret.chars().any(|c| c.is_control())) { | |
| 1052 | bail!("a private registry's credentials must not hold control characters ({url})"); | |
| 1053 | } | |
| 1054 | if registry.kind == "goproxy-server" && secrets.iter().filter_map(|secret| secret.as_deref()).any(|secret| secret.chars().any(char::is_whitespace)) { | |
| 1055 | bail!("a Go proxy's credentials must not hold spaces ({url})"); | |
| 1056 | } | |
| 1057 | if let Some(scope) = registry.scopes.iter().find(|scope| !scope.starts_with('@') || scope.len() < 2 || !scope[1..].chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))) { | |
| 1058 | bail!("{scope:?} is not an npm scope"); | |
| 1059 | } | |
| 1060 | Ok(()) | |
| 1061 | } | |
| 1062 | ||
| 1063 | /// What the tools are given to read private registries: variables for | |
| 1064 | /// every run, files written outside the clone, and a configuration file | |
| 1065 | /// every cargo run is given. | |
| 1066 | #[derive(Default)] | |
| 1067 | struct Tools { | |
| 1068 | env: Vec<(String, String)>, | |
| 1069 | files: Vec<(PathBuf, String)>, | |
| 1070 | cargo_config: Option<String>, | |
| 1071 | } | |
| 1072 | ||
| 1073 | impl Tools { | |
| 1074 | fn set(&mut self, name: impl Into<String>, value: impl Into<String>) { | |
| 1075 | self.env.push((name.into(), value.into())); | |
| 1076 | } | |
| 1077 | ||
| 1078 | fn get(&self, name: &str) -> Option<&str> { | |
| 1079 | self.env.iter().find(|(key, _)| key == name).map(|(_, value)| value.as_str()) | |
| 1080 | } | |
| 1081 | ||
| 1082 | /// Writes the files, readable by their owner only. | |
| 1083 | fn write(&self) -> Result<()> { | |
| 1084 | for (path, text) in &self.files { | |
| 1085 | if let Some(parent) = path.parent() { | |
| 1086 | std::fs::create_dir_all(parent).with_context(|| format!("could not create {}", parent.display()))?; | |
| 1087 | } | |
| 1088 | std::fs::write(path, text).with_context(|| format!("could not write {}", path.display()))?; | |
| 1089 | #[cfg(unix)] | |
| 1090 | { | |
| 1091 | use std::os::unix::fs::PermissionsExt; | |
| 1092 | std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; | |
| 1093 | } | |
| 1094 | } | |
| 1095 | Ok(()) | |
| 1096 | } | |
| 1097 | } | |
| 1098 | ||
| 1099 | /// The configuration that lets the tools read `registries`, with files | |
| 1100 | /// under `dir`. `named` are the registries the project names itself. | |
| 1101 | fn registry_tools(registries: &[Registry], named: &Named, dir: &Path) -> Result<Tools> { | |
| 1102 | let mut tools = Tools::default(); | |
| 1103 | let mut npmrc = String::new(); | |
| 1104 | let mut extra_indexes: Vec<String> = Vec::new(); | |
| 1105 | let mut proxies: Vec<String> = Vec::new(); | |
| 1106 | let mut proxy_replaced = false; | |
| 1107 | let mut netrc = String::new(); | |
| 1108 | let mut cargo_config: Option<toml::Table> = None; | |
| 1109 | for registry in registries { | |
| 1110 | check_registry(registry)?; | |
| 1111 | let url = registry.url.trim(); | |
| 1112 | match registry.kind.as_str() { | |
| 1113 | "npm-registry" => { | |
| 1114 | let nerf = npm_nerf_dart(url); | |
| 1115 | let auth = match (registry.token.as_deref(), registry.username.as_deref(), registry.password.as_deref()) { | |
| 1116 | (Some(token), _, _) if !token.is_empty() => Some(("_authToken", token.to_owned(), "YARN_NPM_AUTH_TOKEN", token.to_owned())), | |
| 1117 | (_, Some(user), Some(password)) => { | |
| 1118 | let ident = format!("{user}:{password}"); | |
| 1119 | let encoded = base64::engine::general_purpose::STANDARD.encode(&ident); | |
| 1120 | Some(("_auth", encoded, "YARN_NPM_AUTH_IDENT", ident)) | |
| 1121 | } | |
| 1122 | _ => None, | |
| 1123 | }; | |
| 1124 | if let Some((key, value, _, _)) = &auth { | |
| 1125 | npmrc.push_str(&format!("{nerf}:{key}={value}\n")); | |
| 1126 | } | |
| 1127 | if registry.replaces_base { | |
| 1128 | npmrc.push_str(&format!("registry={url}\n")); | |
| 1129 | if tools.get("YARN_NPM_REGISTRY_SERVER").is_none() { | |
| 1130 | tools.set("YARN_NPM_REGISTRY_SERVER", url); | |
| 1131 | if let Some((_, _, name, value)) = auth { | |
| 1132 | tools.set(name, value); | |
| 1133 | tools.set("YARN_NPM_ALWAYS_AUTH", "true"); | |
| 1134 | } | |
| 1135 | } | |
| 1136 | } | |
| 1137 | for scope in ®istry.scopes { | |
| 1138 | npmrc.push_str(&format!("{scope}:registry={url}\n")); | |
| 1139 | } | |
| 1140 | } | |
| 1141 | "cargo-registry" => { | |
| 1142 | let secret = registry.secret(); | |
| 1143 | for (name, _) in named.cargo.iter().filter(|(_, index)| same_registry(index, url)) { | |
| 1144 | if let Some(secret) = secret { | |
| 1145 | tools.set(format!("CARGO_REGISTRIES_{}_TOKEN", env_part(name)), secret); | |
| 1146 | } | |
| 1147 | } | |
| 1148 | if registry.replaces_base && cargo_config.is_none() { | |
| 1149 | let index = if url.ends_with(".git") { url.to_owned() } else { format!("sparse+{}/", url.trim_end_matches('/')) }; | |
| 1150 | let table = |pairs: &[(&str, toml::Value)]| -> toml::Value { | |
| 1151 | toml::Value::Table(pairs.iter().map(|(key, value)| (key.to_string(), value.clone())).collect()) | |
| 1152 | }; | |
| 1153 | let text = |text: &str| toml::Value::String(text.to_owned()); | |
| 1154 | let mut config = toml::Table::new(); | |
| 1155 | config.insert( | |
| 1156 | "source".into(), | |
| 1157 | table(&[ | |
| 1158 | ("crates-io", table(&[("replace-with", text("g1t-private"))])), | |
| 1159 | ("g1t-private", table(&[("registry", text(&index))])), | |
| 1160 | ]), | |
| 1161 | ); | |
| 1162 | config.insert("registries".into(), table(&[("g1t-private", table(&[("index", text(&index))]))])); | |
| 1163 | cargo_config = Some(config); | |
| 1164 | if let Some(secret) = secret { | |
| 1165 | tools.set("CARGO_REGISTRIES_G1T_PRIVATE_TOKEN", secret); | |
| 1166 | } | |
| 1167 | } | |
| 1168 | } | |
| 1169 | "python-index" => { | |
| 1170 | let user = registry.username.as_deref().filter(|user| !user.is_empty()); | |
| 1171 | let password = registry.password.as_deref().or(registry.token.as_deref()).filter(|password| !password.is_empty()); | |
| 1172 | let with_auth = match (user, password) { | |
| 1173 | (user, Some(password)) => { | |
| 1174 | let rest = url.strip_prefix("https://").unwrap_or(url); | |
| 1175 | format!("https://{}:{}@{rest}", percent_encode(user.unwrap_or("__token__")), percent_encode(password)) | |
| 1176 | } | |
| 1177 | (Some(user), None) => format!("https://{}@{}", percent_encode(user), url.strip_prefix("https://").unwrap_or(url)), | |
| 1178 | (None, None) => url.to_owned(), | |
| 1179 | }; | |
| 1180 | if registry.replaces_base && tools.get("PIP_INDEX_URL").is_none() { | |
| 1181 | tools.set("PIP_INDEX_URL", with_auth); | |
| 1182 | } else { | |
| 1183 | extra_indexes.push(with_auth); | |
| 1184 | } | |
| 1185 | for (name, _) in named.poetry.iter().filter(|(_, source)| same_registry(source, url)) { | |
| 1186 | if let Some(password) = password { | |
| 1187 | tools.set(format!("POETRY_HTTP_BASIC_{}_USERNAME", env_part(name)), user.unwrap_or("__token__")); | |
| 1188 | tools.set(format!("POETRY_HTTP_BASIC_{}_PASSWORD", env_part(name)), password); | |
| 1189 | } | |
| 1190 | } | |
| 1191 | } | |
| 1192 | "goproxy-server" => { | |
| 1193 | proxies.push(url.to_owned()); | |
| 1194 | proxy_replaced |= registry.replaces_base; | |
| 1195 | if let Some(secret) = registry.secret() { | |
| 1196 | let login = registry.username.as_deref().filter(|user| !user.is_empty()).unwrap_or("g1t"); | |
| 1197 | netrc.push_str(&format!("machine {}\nlogin {login}\npassword {secret}\n", url_host(url))); | |
| 1198 | } | |
| 1199 | } | |
| 1200 | _ => unreachable!("checked above"), | |
| 1201 | } | |
| 1202 | } | |
| 1203 | if !npmrc.is_empty() { | |
| 1204 | let path = dir.join("npmrc"); | |
| 1205 | tools.set("NPM_CONFIG_USERCONFIG", path.display().to_string()); | |
| 1206 | tools.files.push((path, npmrc)); | |
| 1207 | } | |
| 1208 | if !extra_indexes.is_empty() { | |
| 1209 | tools.set("PIP_EXTRA_INDEX_URL", extra_indexes.join(" ")); | |
| 1210 | } | |
| 1211 | if !proxies.is_empty() { | |
| 1212 | let tail = if proxy_replaced { "direct" } else { "https://proxy.golang.org,direct" }; | |
| 1213 | tools.set("GOPROXY", format!("{},{tail}", proxies.join(","))); | |
| 1214 | } | |
| 1215 | if !netrc.is_empty() { | |
| 1216 | let path = dir.join("netrc"); | |
| 1217 | tools.set("NETRC", path.display().to_string()); | |
| 1218 | tools.files.push((path, netrc)); | |
| 1219 | } | |
| 1220 | if let Some(config) = cargo_config { | |
| 1221 | let path = dir.join("cargo.toml"); | |
| 1222 | tools.cargo_config = Some(path.display().to_string()); | |
| 1223 | tools.files.push((path, toml::to_string(&config)?)); | |
| 1224 | } | |
| 1225 | Ok(tools) | |
| 1226 | } | |
| 1227 | ||
| 1228 | /// The registries the clone names itself: Cargo's in `.cargo/config.toml` | |
| 1229 | /// at the root and in each job's directory, Poetry's in each | |
| 1230 | /// `pyproject.toml` updated. | |
| 1231 | fn named_registries(workdir: &Path, jobs: &[Job]) -> Named { | |
| 1232 | let mut named = Named::default(); | |
| 1233 | let mut dirs: Vec<&str> = vec![""]; | |
| 1234 | dirs.extend(jobs.iter().map(|job| job.dir.as_str())); | |
| 1235 | dirs.dedup(); | |
| 1236 | for dir in dirs { | |
| 1237 | for name in [".cargo/config.toml", ".cargo/config"] { | |
| 1238 | if let Ok(text) = std::fs::read_to_string(workdir.join(dir).join(name)) { | |
| 1239 | named.cargo.extend(cargo_registries(&text)); | |
| 1240 | } | |
| 1241 | } | |
| 1242 | } | |
| 1243 | for job in jobs.iter().filter(|job| job.lockfile == Lockfile::PoetryLock) { | |
| 1244 | if let Ok(text) = std::fs::read_to_string(workdir.join(job.file("pyproject.toml"))) { | |
| 1245 | named.poetry.extend(poetry_sources(&text)); | |
| 1246 | } | |
| 1247 | } | |
| 1248 | named | |
| 1249 | } | |
| 1250 | ||
| 1251 | /// Runs a tool in `dir` with `extra` variables and returns what it said, | |
| 1252 | /// failing with the last lines of its output. A tool that is not installed | |
| 1253 | /// is unsupported. The variables are never printed. | |
| 1254 | fn run(dir: &Path, command: &[String], extra: &[(String, String)]) -> Result<String> { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1255 | let (program, args) = command.split_first().ok_or_else(|| anyhow!("no command"))?; |
| 1256 | eprintln!("g1t-runner: {} (in {})", command.join(" "), dir.display()); | |
| 1257 | let output = Command::new(program) | |
| 1258 | .current_dir(dir) | |
| 1259 | .args(args) | |
| 1260 | // Lockfiles only: nothing installs, prompts, audits or runs scripts. | |
| 1261 | .env("CI", "true") | |
| 1262 | .env("npm_config_audit", "false") | |
| 1263 | .env("npm_config_fund", "false") | |
| 1264 | .env("npm_config_update_notifier", "false") | |
| 1265 | .env("npm_config_ignore_scripts", "true") | |
| 1266 | .env("COREPACK_ENABLE_DOWNLOAD_PROMPT", "0") | |
| 1267 | .env("YARN_ENABLE_IMMUTABLE_INSTALLS", "false") | |
| 1268 | .env("YARN_ENABLE_SCRIPTS", "false") | |
| 1269 | .env("YARN_ENABLE_TELEMETRY", "0") | |
| 1270 | .env("POETRY_NO_INTERACTION", "1") | |
| 1271 | .env("POETRY_VIRTUALENVS_CREATE", "false") | |
| 1272 | .env("GOFLAGS", "-mod=mod") | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1273 | .envs(extra.iter().map(|(name, value)| (name, value))) |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1274 | .output() |
| 1275 | .map_err(|error| { | |
| 1276 | if error.kind() == std::io::ErrorKind::NotFound { | |
| 1277 | unsupported(format!("{program} is not installed in this sandbox")) | |
| 1278 | } else { | |
| 1279 | anyhow!("could not run {program}: {error}") | |
| 1280 | } | |
| 1281 | })?; | |
| 1282 | let said = format!("{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)); | |
| 1283 | if !output.status.success() { | |
| 1284 | bail!("{} failed:\n{}", command.join(" "), tail(&said, 20)); | |
| 1285 | } | |
| 1286 | Ok(said) | |
| 1287 | } | |
| 1288 | ||
| 1289 | fn read(path: &Path) -> Result<String> { | |
| 1290 | std::fs::read_to_string(path).with_context(|| format!("could not read {}", path.display())) | |
| 1291 | } | |
| 1292 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1293 | /// Poetry, installed into a virtual environment from PyPI (or the index |
| 1294 | /// that replaces it) when the sandbox has none. | |
| 1295 | fn poetry(extra: &[(String, String)]) -> Result<Vec<String>> { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1296 | if Command::new("poetry").arg("--version").output().is_ok_and(|output| output.status.success()) { |
| 1297 | return Ok(vec!["poetry".to_owned()]); | |
| 1298 | } | |
| 1299 | let venv = "/tmp/g1t-poetry"; | |
| 1300 | let here = Path::new("/"); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1301 | run(here, &["python3", "-m", "venv", venv].map(str::to_owned), extra)?; |
| 1302 | run(here, &[format!("{venv}/bin/pip"), "install".into(), "--quiet".into(), "poetry".into()], extra)?; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1303 | Ok(vec![format!("{venv}/bin/poetry")]) |
| 1304 | } | |
| 1305 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1306 | /// What one update of one job did: what the tools said when they failed, |
| 1307 | /// and the files other than the job's own it changed. | |
| 1308 | #[derive(Default)] | |
| 1309 | struct Outcome { | |
| 1310 | said: Vec<String>, | |
| 1311 | files: Vec<String>, | |
| 1312 | } | |
| 1313 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1314 | impl Bump { |
| 1315 | fn from_env() -> Result<Bump> { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1316 | let optional = |name: &str| std::env::var(name).ok(); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1317 | let ecosystem_name = env("BUMP_ECOSYSTEM")?; |
| 1318 | let ecosystem = Ecosystem::parse(ecosystem_name.trim()) | |
| 1319 | .ok_or_else(|| unsupported(format!("g1t cannot update {ecosystem_name} dependencies")))?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1320 | let kind = Kind::parse(&optional("BUMP_KIND").unwrap_or_default())?; |
| 1321 | let packages = targets( | |
| 1322 | ecosystem, | |
| 1323 | optional("BUMP_PACKAGES").as_deref(), | |
| 1324 | optional("BUMP_PACKAGE").as_deref(), | |
| 1325 | optional("BUMP_VERSION").as_deref(), | |
| 1326 | )?; | |
| 1327 | let strategy = Strategy::parse(&optional("BUMP_STRATEGY").unwrap_or_default())?; | |
| 1328 | let force = matches!(optional("BUMP_FORCE").as_deref().map(str::trim), Some("1" | "true")); | |
| 1329 | let registries: Vec<Registry> = match optional("BUMP_REGISTRIES").as_deref().map(str::trim).filter(|text| !text.is_empty()) { | |
| 1330 | Some(text) => serde_json::from_str(text).context("BUMP_REGISTRIES is not a JSON array of registries")?, | |
| 1331 | None => Vec::new(), | |
| 1332 | }; | |
| 1333 | registries.iter().try_for_each(check_registry)?; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1334 | let jobs = jobs(ecosystem, &lockfile_list(&env("BUMP_LOCKFILES")?)?)?; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1335 | Ok(Bump { |
| 1336 | ecosystem, | |
| 1337 | kind, | |
| 1338 | packages, | |
| 1339 | strategy: (kind == Kind::Version).then_some(strategy), | |
| 1340 | force, | |
| 1341 | registries, | |
| 1342 | jobs, | |
| 1343 | }) | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1344 | } |
| 1345 | ||
| 1346 | /// The versions every lockfile of `job` still resolves below the target. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1347 | fn still_below(&self, workdir: &Path, job: &Job, target: &Target) -> Result<Vec<String>> { |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1348 | let mut found = BTreeSet::new(); |
| 1349 | for path in &job.paths { | |
| 1350 | let lockfile = Lockfile::for_path(path).unwrap_or(job.lockfile); | |
| 1351 | let file = workdir.join(path); | |
| 1352 | if !file.exists() { | |
| 1353 | bail!("{path} is not in the repository's default branch"); | |
| 1354 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1355 | found.extend(below(lockfile, &read(&file)?, self.ecosystem, &target.package, &target.version)); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1356 | } |
| 1357 | Ok(found.into_iter().collect()) | |
| 1358 | } | |
| 1359 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1360 | /// Runs the tool for one job and one package. Errors from the tool are |
| 1361 | /// kept for the reason, should the lockfile still be behind afterwards. | |
| 1362 | fn update(&self, workdir: &Path, job: &Job, target: &Target, tools: &Tools) -> Result<Outcome> { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1363 | let dir = workdir.join(&job.dir); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1364 | let (package, version) = (target.package.as_str(), target.version.as_str()); |
| 1365 | let mut outcome = Outcome::default(); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1366 | let attempt = |command: Vec<String>, said: &mut Vec<String>| -> Result<bool> { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1367 | match run(&dir, &command, &tools.env) { |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1368 | Ok(_) => Ok(true), |
| 1369 | Err(error) if error.downcast_ref::<Stop>().is_some() => Err(error), | |
| 1370 | Err(error) => { | |
| 1371 | said.push(format!("{error:#}")); | |
| 1372 | Ok(false) | |
| 1373 | } | |
| 1374 | } | |
| 1375 | }; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1376 | let said = &mut outcome.said; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1377 | match job.lockfile { |
| 1378 | Lockfile::PackageLock | Lockfile::PnpmLock | Lockfile::YarnLock => { | |
| 1379 | let lock = read(&workdir.join(&job.paths[0]))?; | |
| 1380 | let node = Node::of(job.lockfile, &lock).ok_or_else(|| anyhow!("not a JavaScript lockfile"))?; | |
| 1381 | let manifest_path = dir.join("package.json"); | |
| 1382 | let mut manifest: Value = serde_json::from_str(&read(&manifest_path)?).context("package.json is not JSON")?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1383 | match direct_range(&manifest, package) { |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1384 | Some(range) => { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1385 | let plan = direct_plan(self.strategy, &range, version); |
| 1386 | if plan.in_range_first { | |
| 1387 | attempt(node.in_range(package), said)?; | |
| 1388 | } | |
| 1389 | if !plan.in_range_first || !self.still_below(workdir, job, target)?.is_empty() { | |
| 1390 | match plan.range { | |
| 1391 | Some(range) => { | |
| 1392 | attempt(node.direct(package, &range), said)?; | |
| 1393 | } | |
| 1394 | None => said.push(format!("{package} {LOCKFILE_ONLY}")), | |
| 1395 | } | |
| 1396 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1397 | } |
| 1398 | None => { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1399 | if let Some(command) = node.transitive(package) { |
| 1400 | attempt(command, said)?; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1401 | } |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1402 | // Held back by what asks for it: a security update |
| 1403 | // forces the version; a version update never does. | |
| 1404 | if self.kind == Kind::Security && !self.still_below(workdir, job, target)?.is_empty() { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1405 | manifest = serde_json::from_str(&read(&manifest_path)?).context("package.json is not JSON")?; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1406 | if add_override(&mut manifest, node.override_path(), package, version)? { |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1407 | let indent = if read(&manifest_path)?.contains("\n \"") { " " } else { " " }; |
| 1408 | write_json(&manifest_path, &manifest, indent)?; | |
| 1409 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1410 | attempt(node.relock(), said)?; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1411 | } |
| 1412 | } | |
| 1413 | } | |
| 1414 | } | |
| 1415 | Lockfile::CargoLock => { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1416 | let config = tools.cargo_config.as_deref(); |
| 1417 | let raise = matches!(self.strategy, Some(Strategy::Increase | Strategy::IncreaseIfNecessary | Strategy::Widen)); | |
| 1418 | for old in self.still_below(workdir, job, target)? { | |
| 1419 | let [precise, compatible] = cargo_commands(package, &old, version, config); | |
| 1420 | if attempt(precise.clone(), said)? { | |
| 1421 | continue; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1422 | } |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1423 | // The requirement does not allow it: raise it, then try again. |
| 1424 | if raise { | |
| 1425 | let mut raised = 0; | |
| 1426 | for manifest in cargo_manifests(workdir, job) { | |
| 1427 | let file = workdir.join(&manifest); | |
| 1428 | let (text, moved) = rewrite_cargo_requirements(&read(&file)?, package, version); | |
| 1429 | if moved > 0 { | |
| 1430 | std::fs::write(&file, text).with_context(|| format!("could not write {manifest}"))?; | |
| 1431 | outcome.files.push(manifest); | |
| 1432 | raised += moved; | |
| 1433 | } | |
| 1434 | } | |
| 1435 | if raised > 0 && attempt(precise, said)? { | |
| 1436 | continue; | |
| 1437 | } | |
| 1438 | } | |
| 1439 | attempt(compatible, said)?; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1440 | } |
| 1441 | } | |
| 1442 | Lockfile::GoMod | Lockfile::GoSum => { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1443 | for command in go_commands(package, version) { |
| 1444 | if !attempt(command, said)? { | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1445 | break; |
| 1446 | } | |
| 1447 | } | |
| 1448 | } | |
| 1449 | Lockfile::PoetryLock => { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1450 | let group = if self.strategy == Some(Strategy::LockfileOnly) { |
| 1451 | None | |
| 1452 | } else { | |
| 1453 | let pyproject: toml::Value = toml::from_str(&read(&dir.join("pyproject.toml"))?).context("pyproject.toml is not TOML")?; | |
| 1454 | poetry_group(&pyproject, package) | |
| 1455 | }; | |
| 1456 | attempt(poetry_commands(&poetry(&tools.env)?, package, version, group), said)?; | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1457 | } |
| 1458 | Lockfile::Requirements => { | |
| 1459 | for path in &job.paths { | |
| 1460 | let file = workdir.join(path); | |
| 1461 | let text = read(&file)?; | |
| 1462 | if text.contains("--hash") { | |
| 1463 | return Err(unsupported(format!("{path} pins hashes, which need its compiler to update"))); | |
| 1464 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1465 | let (rewritten, moved) = rewrite_pins(&text, package, version); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1466 | if moved > 0 { |
| 1467 | std::fs::write(&file, rewritten).with_context(|| format!("could not write {path}"))?; | |
| 1468 | } | |
| 1469 | } | |
| 1470 | } | |
| 1471 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1472 | Ok(outcome) |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1473 | } |
| 1474 | } | |
| 1475 | ||
| 1476 | /// Writes JSON as package managers do: indented, with a final newline. | |
| 1477 | fn write_json(path: &Path, value: &Value, indent: &str) -> Result<()> { | |
| 1478 | let mut out = Vec::new(); | |
| 1479 | let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); | |
| 1480 | let mut serializer = serde_json::Serializer::with_formatter(&mut out, formatter); | |
| 1481 | serde::Serialize::serialize(value, &mut serializer)?; | |
| 1482 | out.push(b'\n'); | |
| 1483 | std::fs::write(path, out).with_context(|| format!("could not write {}", path.display())) | |
| 1484 | } | |
| 1485 | ||
| 1486 | /// What was pushed. | |
| 1487 | struct Pushed { | |
| 1488 | commit: String, | |
| 1489 | /// The branch was there already, with the same files. | |
| 1490 | existed: bool, | |
| 1491 | } | |
| 1492 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1493 | /// The packages as a reason names them: `lodash 4.17.21, vitest 1.6.0`. |
| 1494 | fn package_list(packages: &[Target]) -> String { | |
| 1495 | packages.iter().map(|target| format!("{} {}", target.package, target.version)).collect::<Vec<_>>().join(", ") | |
| 1496 | } | |
| 1497 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1498 | fn bump() -> Result<(Bump, String, Pushed)> { |
| 1499 | let bump = Bump::from_env()?; | |
| 1500 | let remote = env("GIT_REMOTE")?; | |
| 1501 | let base = env("GIT_BRANCH_BASE")?; | |
| 1502 | let branch = env("GIT_BRANCH")?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1503 | check_branch(bump.kind, &branch, &base)?; |
| 1504 | let message = env("COMMIT_MESSAGE").unwrap_or_else(|_| match bump.packages.as_slice() { | |
| 1505 | [only] => format!("Update {} to {}", only.package, only.version), | |
| 1506 | [first, rest @ ..] => format!("Update {} and {} more", first.package, rest.len()), | |
| 1507 | [] => "Update dependencies".to_owned(), | |
| 1508 | }); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1509 | let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?); |
| 1510 | let workdir = Path::new(WORKDIR); | |
| 1511 | ||
| 1512 | std::fs::create_dir_all("/work")?; | |
| 1513 | crate::clone::clone(Path::new("/work"), &auth, &["--branch", &base], &remote, WORKDIR) | |
| 1514 | .with_context(|| format!("could not clone {base}"))?; | |
| 1515 | git(workdir, &["checkout", "--quiet", "-b", &branch])?; | |
| 1516 | git(workdir, &["config", "user.name", AUTHOR_NAME])?; | |
| 1517 | git(workdir, &["config", "user.email", AUTHOR_EMAIL])?; | |
| 1518 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1519 | let tools = registry_tools(&bump.registries, &named_registries(workdir, &bump.jobs), Path::new(REGISTRY_DIR))?; |
| 1520 | tools.write()?; | |
| 1521 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1522 | let mut behind = Vec::new(); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1523 | let mut changed: Vec<String> = Vec::new(); |
| 1524 | for target in &bump.packages { | |
| 1525 | for job in &bump.jobs { | |
| 1526 | if bump.still_below(workdir, job, target)?.is_empty() { | |
| 1527 | continue; // Already at the version or later here. | |
| 1528 | } | |
| 1529 | let outcome = bump.update(workdir, job, target, &tools)?; | |
| 1530 | changed.extend(outcome.files); | |
| 1531 | let left = bump.still_below(workdir, job, target)?; | |
| 1532 | if !left.is_empty() { | |
| 1533 | let why = outcome.said.last().map(|said| format!("\n{said}")).unwrap_or_default(); | |
| 1534 | behind.push(format!( | |
| 1535 | "{} still resolves {} {} (wanted {} or later){why}", | |
| 1536 | job.paths.join(", "), | |
| 1537 | target.package, | |
| 1538 | left.join(", "), | |
| 1539 | target.version | |
| 1540 | )); | |
| 1541 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1542 | } |
| 1543 | } | |
| 1544 | if !behind.is_empty() { | |
| 1545 | return Err(needs_changes(behind.join("\n\n"))); | |
| 1546 | } | |
| 1547 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1548 | let mut touched: Vec<String> = bump.jobs.iter().flat_map(Job::touched).chain(changed).collect(); |
| 1549 | touched.sort(); | |
| 1550 | touched.dedup(); | |
| 1551 | touched.retain(|path| workdir.join(path).exists()); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1552 | let mut add = vec!["add", "--"]; |
| 1553 | add.extend(touched.iter().map(String::as_str)); | |
| 1554 | git(workdir, &add)?; | |
| 1555 | if git(workdir, &["diff", "--cached", "--name-only"])?.is_empty() { | |
| 1556 | bail!( | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1557 | "nothing to change: {} already resolves {} or later", |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1558 | bump.jobs.iter().flat_map(|job| job.paths.iter().map(String::as_str)).collect::<Vec<_>>().join(", "), |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1559 | package_list(&bump.packages) |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1560 | ); |
| 1561 | } | |
| 1562 | git(workdir, &["commit", "--quiet", "--message", &message])?; | |
| 1563 | let commit = git(workdir, &["rev-parse", "HEAD"])?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1564 | let refspec = format!("{}HEAD:refs/heads/{branch}", if bump.force { "+" } else { "" }); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1565 | let pushed = git(workdir, &["-c", &auth, "push", "--quiet", "origin", &refspec]); |
| 1566 | if let Err(error) = pushed { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1567 | if bump.force { |
| 1568 | return Err(error.context("could not push the update")); | |
| 1569 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1570 | // Pushed before (a retried job): the same files there is success. |
| 1571 | let theirs = git(workdir, &["-c", &auth, "ls-remote", "origin", &format!("refs/heads/{branch}")]).unwrap_or_default(); | |
| 1572 | if theirs.is_empty() { | |
| 1573 | return Err(error.context("could not push the update")); | |
| 1574 | } | |
| 1575 | crate::clone::fetch(workdir, &auth, "origin", &format!("refs/heads/{branch}")).context("could not read the branch already pushed")?; | |
| 1576 | let same = git(workdir, &["rev-parse", "FETCH_HEAD^{tree}"])? == git(workdir, &["rev-parse", "HEAD^{tree}"])?; | |
| 1577 | if !same { | |
| 1578 | return Err(error.context(format!("{branch} already exists with other changes"))); | |
| 1579 | } | |
| 1580 | let commit = git(workdir, &["rev-parse", "FETCH_HEAD"])?; | |
| 1581 | return Ok((bump, branch, Pushed { commit, existed: true })); | |
| 1582 | } | |
| 1583 | Ok((bump, branch, Pushed { commit, existed: false })) | |
| 1584 | } | |
| 1585 | ||
| 1586 | pub fn main() -> i32 { | |
| 1587 | match bump() { | |
| 1588 | Ok((bump, branch, pushed)) => { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1589 | let first = &bump.packages[0]; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1590 | println!( |
| 1591 | "{}", | |
| 1592 | json!({ | |
| 1593 | "bump": if pushed.existed { "exists" } else { "pushed" }, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1594 | "kind": bump.kind.name(), |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1595 | "branch": branch, |
| 1596 | "commit": pushed.commit, | |
| 1597 | "ecosystem": bump.ecosystem.osv(), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1598 | "package": first.package, |
| 1599 | "version": first.version, | |
| 1600 | "packages": bump.packages, | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1601 | "lockfiles": bump.jobs.iter().flat_map(|job| job.paths.clone()).collect::<Vec<_>>(), |
| 1602 | }) | |
| 1603 | ); | |
| 1604 | 0 | |
| 1605 | } | |
| 1606 | Err(error) => { | |
| 1607 | let (reason, code) = match error.downcast_ref::<Stop>() { | |
| 1608 | Some(Stop::NeedsChanges(_)) => ("needs_code_changes", NEEDS_CHANGES_EXIT), | |
| 1609 | Some(Stop::Unsupported(_)) => ("unsupported", UNSUPPORTED_EXIT), | |
| 1610 | None => ("failed", 1), | |
| 1611 | }; | |
| 1612 | println!("{}", json!({ "bump": "failed", "reason": reason, "message": format!("{error:#}") })); | |
| 1613 | eprintln!("g1t-runner: {error:#}"); | |
| 1614 | code | |
| 1615 | } | |
| 1616 | } | |
| 1617 | } | |
| 1618 | ||
| 1619 | #[cfg(test)] | |
| 1620 | mod tests { | |
| 1621 | use super::*; | |
| 1622 | ||
| 1623 | fn strings(items: &[&str]) -> Vec<String> { | |
| 1624 | items.iter().map(|item| item.to_string()).collect() | |
| 1625 | } | |
| 1626 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1627 | fn target(package: &str, version: &str) -> Target { |
| 1628 | Target { package: package.into(), version: version.into() } | |
| 1629 | } | |
| 1630 | ||
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1631 | #[test] |
| 1632 | fn lockfiles_come_as_lines_or_json() { | |
| 1633 | assert_eq!(lockfile_list("Cargo.lock\n web/package-lock.json \n\n").unwrap(), ["Cargo.lock", "web/package-lock.json"]); | |
| 1634 | assert_eq!(lockfile_list(r#"["./go.mod","go.sum"]"#).unwrap(), ["go.mod", "go.sum"]); | |
| 1635 | assert!(lockfile_list("[not json").is_err()); | |
| 1636 | } | |
| 1637 | ||
| 1638 | #[test] | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1639 | fn packages_come_as_a_list_or_one() { |
| 1640 | // A security update's one package, as before. | |
| 1641 | assert_eq!(targets(Ecosystem::Npm, None, Some(" lodash "), Some("v4.17.21")).unwrap(), [target("lodash", "4.17.21")]); | |
| 1642 | assert_eq!(targets(Ecosystem::Npm, Some("[]"), Some("lodash"), Some("4.17.21")).unwrap(), [target("lodash", "4.17.21")]); | |
| 1643 | assert!(targets(Ecosystem::Npm, None, None, Some("1.0.0")).is_err()); | |
| 1644 | // A group, each version as the tool takes it, a package once. | |
| 1645 | let listed = r#"[{"package":"golang.org/x/net","version":"0.23.0"},{"package":"golang.org/x/text","version":"v0.14.0"},{"package":"golang.org/x/net","version":"0.24.0"}]"#; | |
| 1646 | assert_eq!( | |
| 1647 | targets(Ecosystem::Go, Some(listed), Some("ignored"), Some("1")).unwrap(), | |
| 1648 | [target("golang.org/x/net", "v0.23.0"), target("golang.org/x/text", "v0.14.0")] | |
| 1649 | ); | |
| 1650 | assert!(targets(Ecosystem::Npm, Some(r#"[{"package":"--registry=evil","version":"1"}]"#), None, None).is_err()); | |
| 1651 | assert!(targets(Ecosystem::Npm, Some(r#"[{"package":"a","version":"1;rm"}]"#), None, None).is_err()); | |
| 1652 | assert!(targets(Ecosystem::Npm, Some("{"), None, None).is_err()); | |
| 1653 | let many = format!("[{}]", (0..51).map(|i| format!(r#"{{"package":"p{i}","version":"1.0.0"}}"#)).collect::<Vec<_>>().join(",")); | |
| 1654 | assert!(targets(Ecosystem::Npm, Some(&many), None, None).is_err()); | |
| 1655 | } | |
| 1656 | ||
| 1657 | #[test] | |
| 1658 | fn kinds_and_strategies_by_name() { | |
| 1659 | assert_eq!(Kind::parse("").unwrap(), Kind::Security); | |
| 1660 | assert_eq!(Kind::parse("security").unwrap(), Kind::Security); | |
| 1661 | assert_eq!(Kind::parse("version").unwrap(), Kind::Version); | |
| 1662 | assert!(Kind::parse("major").is_err()); | |
| 1663 | assert_eq!(Strategy::parse("").unwrap(), Strategy::Increase); | |
| 1664 | assert_eq!(Strategy::parse("increase-if-necessary").unwrap(), Strategy::IncreaseIfNecessary); | |
| 1665 | assert_eq!(Strategy::parse("widen").unwrap(), Strategy::Widen); | |
| 1666 | assert_eq!(Strategy::parse("lockfile-only").unwrap(), Strategy::LockfileOnly); | |
| 1667 | assert!(Strategy::parse("auto").is_err()); | |
| 1668 | } | |
| 1669 | ||
| 1670 | #[test] | |
| 1671 | fn branches_by_kind() { | |
| 1672 | assert!(check_branch(Kind::Security, "g1t/security/lodash-4.17.21", "main").is_ok()); | |
| 1673 | for branch in ["main", "deps/lodash", "g1t/security/a..b", "g1t/security/a b"] { | |
| 1674 | assert!(check_branch(Kind::Security, branch, "main").is_err(), "{branch}"); | |
| 1675 | } | |
| 1676 | for branch in ["deps/npm/lodash", "dependabot/cargo/serde-1.0.200", "g1t/security/x", "develop"] { | |
| 1677 | assert!(check_branch(Kind::Version, branch, "main").is_ok(), "{branch}"); | |
| 1678 | } | |
| 1679 | assert!(check_branch(Kind::Version, "main", "main").is_err()); | |
| 1680 | let long = "x".repeat(201); | |
| 1681 | for branch in ["", " ", "a b", "a..b", "a~1", "a^", "a:b", "a?", "a*", "a[b", "a\\b", "a@{1}", "-x", "/x", "refs/heads/x", "x/", "x.lock", "a\u{7}", long.as_str()] { | |
| 1682 | assert!(check_branch(Kind::Version, branch, "main").is_err(), "{branch:?}"); | |
| 1683 | } | |
| 1684 | } | |
| 1685 | ||
| 1686 | #[test] | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1687 | fn lockfiles_group_by_directory_and_tool() { |
| 1688 | let found = jobs(Ecosystem::Go, &strings(&["go.mod", "go.sum", "tools/go.sum"])).unwrap(); | |
| 1689 | assert_eq!( | |
| 1690 | found, | |
| 1691 | [ | |
| 1692 | Job { dir: String::new(), lockfile: Lockfile::GoMod, paths: strings(&["go.mod", "go.sum"]) }, | |
| 1693 | Job { dir: "tools".into(), lockfile: Lockfile::GoMod, paths: strings(&["tools/go.sum"]) }, | |
| 1694 | ] | |
| 1695 | ); | |
| 1696 | assert_eq!(found[0].touched(), ["go.mod", "go.sum"]); | |
| 1697 | let web = jobs(Ecosystem::Npm, &strings(&["web/package-lock.json"])).unwrap(); | |
| 1698 | assert_eq!(web[0].touched(), ["web/package-lock.json", "web/package.json"]); | |
| 1699 | } | |
| 1700 | ||
| 1701 | #[test] | |
| 1702 | fn lockfiles_must_be_the_ecosystems_and_inside_the_repository() { | |
| 1703 | assert!(jobs(Ecosystem::Npm, &strings(&["Cargo.lock"])).is_err()); | |
| 1704 | assert!(jobs(Ecosystem::Npm, &strings(&["../package-lock.json"])).is_err()); | |
| 1705 | assert!(jobs(Ecosystem::Npm, &strings(&["/etc/package-lock.json"])).is_err()); | |
| 1706 | assert!(jobs(Ecosystem::Npm, &[]).is_err()); | |
| 1707 | let error = jobs(Ecosystem::PyPI, &strings(&["uv.lock"])).unwrap_err(); | |
| 1708 | assert!(matches!(error.downcast_ref::<Stop>(), Some(Stop::Unsupported(_)))); | |
| 1709 | } | |
| 1710 | ||
| 1711 | #[test] | |
| 1712 | fn versions_as_each_tool_takes_them() { | |
| 1713 | assert_eq!(tool_version(Ecosystem::Go, "0.17.0"), "v0.17.0"); | |
| 1714 | assert_eq!(tool_version(Ecosystem::Go, "v0.17.0"), "v0.17.0"); | |
| 1715 | assert_eq!(tool_version(Ecosystem::Npm, "v4.17.21"), "4.17.21"); | |
| 1716 | assert_eq!(tool_version(Ecosystem::Cargo, " 1.6.1 "), "1.6.1"); | |
| 1717 | assert_eq!(tool_version(Ecosystem::PyPI, "2.31.0"), "2.31.0"); | |
| 1718 | } | |
| 1719 | ||
| 1720 | #[test] | |
| 1721 | fn names_and_versions_cannot_be_options() { | |
| 1722 | assert!(safe_argument("package", "@babel/core").is_ok()); | |
| 1723 | assert!(safe_argument("package", "golang.org/x/net").is_ok()); | |
| 1724 | assert!(safe_argument("version", "1.2.3-rc.1+build").is_ok()); | |
| 1725 | assert!(safe_argument("package", "--registry=evil").is_err()); | |
| 1726 | assert!(safe_argument("package", "a b").is_err()); | |
| 1727 | assert!(safe_argument("version", "1.0;rm").is_err()); | |
| 1728 | assert!(safe_argument("version", "").is_err()); | |
| 1729 | } | |
| 1730 | ||
| 1731 | #[test] | |
| 1732 | fn a_direct_dependency_keeps_its_range_style() { | |
| 1733 | assert_eq!(raised_range("^4.17.0", "4.17.21"), "^4.17.21"); | |
| 1734 | assert_eq!(raised_range("~1.2.0", "1.2.5"), "~1.2.5"); | |
| 1735 | assert_eq!(raised_range("1.2.0", "1.2.5"), "1.2.5"); | |
| 1736 | assert_eq!(raised_range("=1.2.0", "1.2.5"), "1.2.5"); | |
| 1737 | assert_eq!(raised_range(">=1 <2", "1.2.5"), "^1.2.5"); | |
| 1738 | assert_eq!(raised_range("*", "1.2.5"), "^1.2.5"); | |
| 1739 | } | |
| 1740 | ||
| 1741 | #[test] | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1742 | fn widen_keeps_what_was_allowed() { |
| 1743 | assert_eq!(widened_range("^1.2.0", "2.0.0"), "^1.2.0 || ^2.0.0"); | |
| 1744 | assert_eq!(widened_range(" ~1.2.0 ", "1.3.0"), "~1.2.0 || ~1.3.0"); | |
| 1745 | assert_eq!(widened_range("1.2.0", "2.0.0"), "1.2.0 || 2.0.0"); | |
| 1746 | assert_eq!(widened_range("^1.0.0 || ^2.0.0", "3.1.0"), "^1.0.0 || ^2.0.0 || ^3.1.0"); | |
| 1747 | } | |
| 1748 | ||
| 1749 | #[test] | |
| 1750 | fn strategies_plan_a_direct_javascript_dependency() { | |
| 1751 | let raised = |in_range_first, range: &str| DirectPlan { in_range_first, range: Some(range.to_owned()) }; | |
| 1752 | // A security update, and increase: the range raised, at once. | |
| 1753 | assert_eq!(direct_plan(None, "^1.2.0", "2.0.0"), raised(false, "^2.0.0")); | |
| 1754 | assert_eq!(direct_plan(Some(Strategy::Increase), "~1.2.0", "1.3.0"), raised(false, "~1.3.0")); | |
| 1755 | // The others first take what the range allows. | |
| 1756 | assert_eq!(direct_plan(Some(Strategy::IncreaseIfNecessary), "^1.2.0", "2.0.0"), raised(true, "^2.0.0")); | |
| 1757 | assert_eq!(direct_plan(Some(Strategy::Widen), "^1.2.0", "2.0.0"), raised(true, "^1.2.0 || ^2.0.0")); | |
| 1758 | assert_eq!(direct_plan(Some(Strategy::LockfileOnly), "^1.2.0", "2.0.0"), DirectPlan { in_range_first: true, range: None }); | |
| 1759 | } | |
| 1760 | ||
| 1761 | #[test] | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1762 | fn direct_dependencies_from_the_registry_only() { |
| 1763 | let manifest = json!({ | |
| 1764 | "dependencies": { "lodash": "^4.17.0", "local": "file:../local", "shared": "workspace:*" }, | |
| 1765 | "devDependencies": { "vitest": "~1.0.0", "fork": "github:me/fork" }, | |
| 1766 | }); | |
| 1767 | assert_eq!(direct_range(&manifest, "lodash").as_deref(), Some("^4.17.0")); | |
| 1768 | assert_eq!(direct_range(&manifest, "vitest").as_deref(), Some("~1.0.0")); | |
| 1769 | assert_eq!(direct_range(&manifest, "local"), None); | |
| 1770 | assert_eq!(direct_range(&manifest, "shared"), None); | |
| 1771 | assert_eq!(direct_range(&manifest, "fork"), None); | |
| 1772 | assert_eq!(direct_range(&manifest, "minimist"), None); | |
| 1773 | } | |
| 1774 | ||
| 1775 | #[test] | |
| 1776 | fn javascript_commands_by_lockfile() { | |
| 1777 | let npm = Node::of(Lockfile::PackageLock, "{}").unwrap(); | |
| 1778 | assert_eq!( | |
| 1779 | npm.direct("lodash", "^4.17.21"), | |
| 1780 | strings(&["npm", "install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", "lodash@^4.17.21"]) | |
| 1781 | ); | |
| 1782 | assert_eq!( | |
| 1783 | npm.transitive("minimist").unwrap(), | |
| 1784 | strings(&["npm", "update", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", "minimist"]) | |
| 1785 | ); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1786 | assert_eq!( |
| 1787 | npm.in_range("lodash"), | |
| 1788 | strings(&["npm", "update", "--package-lock-only", "--no-save", "--ignore-scripts", "--no-audit", "--no-fund", "lodash"]) | |
| 1789 | ); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1790 | assert_eq!(npm.override_path(), ["overrides"]); |
| 1791 | ||
| 1792 | let pnpm = Node::of(Lockfile::PnpmLock, "lockfileVersion: '9.0'").unwrap(); | |
| 1793 | assert_eq!(pnpm.direct("lodash", "^4.17.21"), strings(&["corepack", "pnpm", "update", "lodash@^4.17.21", "--lockfile-only", "--ignore-scripts"])); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1794 | assert_eq!(pnpm.in_range("lodash"), strings(&["corepack", "pnpm", "update", "lodash", "--lockfile-only", "--no-save", "--ignore-scripts"])); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1795 | assert_eq!(pnpm.override_path(), ["pnpm", "overrides"]); |
| 1796 | ||
| 1797 | let berry = Node::of(Lockfile::YarnLock, "__metadata:\n version: 6\n").unwrap(); | |
| 1798 | assert_eq!(berry, Node::YarnBerry); | |
| 1799 | assert_eq!(berry.direct("lodash", "^4.17.21"), strings(&["corepack", "yarn", "up", "lodash@^4.17.21", "--mode=update-lockfile"])); | |
| 1800 | assert_eq!(berry.transitive("minimist").unwrap(), strings(&["corepack", "yarn", "up", "--recursive", "minimist", "--mode=update-lockfile"])); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1801 | assert_eq!(berry.in_range("lodash"), strings(&["corepack", "yarn", "up", "--recursive", "lodash", "--mode=update-lockfile"])); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1802 | |
| 1803 | let classic = Node::of(Lockfile::YarnLock, "# yarn lockfile v1\n").unwrap(); | |
| 1804 | assert_eq!(classic, Node::YarnClassic); | |
| 1805 | assert_eq!(classic.transitive("minimist"), None); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1806 | assert_eq!(classic.in_range("lodash"), strings(&["corepack", "yarn", "upgrade", "lodash", "--ignore-scripts", "--non-interactive"])); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1807 | assert_eq!(classic.override_path(), ["resolutions"]); |
| 1808 | assert_eq!(Node::of(Lockfile::CargoLock, ""), None); | |
| 1809 | } | |
| 1810 | ||
| 1811 | #[test] | |
| 1812 | fn overrides_are_added_once() { | |
| 1813 | let mut manifest = json!({ "name": "app" }); | |
| 1814 | assert!(add_override(&mut manifest, &["pnpm", "overrides"], "minimist", "1.2.6").unwrap()); | |
| 1815 | assert_eq!(manifest["pnpm"]["overrides"]["minimist"], "1.2.6"); | |
| 1816 | assert!(!add_override(&mut manifest, &["pnpm", "overrides"], "minimist", "1.2.6").unwrap()); | |
| 1817 | assert!(add_override(&mut manifest, &["pnpm", "overrides"], "minimist", "1.2.8").unwrap()); | |
| 1818 | assert_eq!(manifest["pnpm"]["overrides"]["minimist"], "1.2.8"); | |
| 1819 | } | |
| 1820 | ||
| 1821 | #[test] | |
| 1822 | fn cargo_and_go_commands() { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1823 | let [precise, compatible] = cargo_commands("time", "0.1.43", "0.1.45", None); |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1824 | assert_eq!(precise, strings(&["cargo", "update", "-p", "time@0.1.43", "--precise", "0.1.45"])); |
| 1825 | assert_eq!(compatible, strings(&["cargo", "update", "-p", "time@0.1.43"])); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1826 | let [precise, _] = cargo_commands("time", "0.1.43", "0.1.45", Some("/tmp/g1t-registries/cargo.toml")); |
| 1827 | assert_eq!(precise, strings(&["cargo", "--config", "/tmp/g1t-registries/cargo.toml", "update", "-p", "time@0.1.43", "--precise", "0.1.45"])); | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1828 | let [get, tidy] = go_commands("golang.org/x/net", "v0.23.0"); |
| 1829 | assert_eq!(get, strings(&["go", "get", "golang.org/x/net@v0.23.0"])); | |
| 1830 | assert_eq!(tidy, strings(&["go", "mod", "tidy"])); | |
| 1831 | } | |
| 1832 | ||
| 1833 | #[test] | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1834 | fn cargo_requirements_keep_their_operator() { |
| 1835 | assert_eq!(raised_requirement("1.2", "2.0.3").as_deref(), Some("2.0.3")); | |
| 1836 | assert_eq!(raised_requirement("^1.2.0", "2.0.3").as_deref(), Some("^2.0.3")); | |
| 1837 | assert_eq!(raised_requirement("~0.4", "0.5.1").as_deref(), Some("~0.5.1")); | |
| 1838 | assert_eq!(raised_requirement("=1.0.0", "1.1.0").as_deref(), Some("=1.1.0")); | |
| 1839 | assert_eq!(raised_requirement(">= 1.0", "2.0.0").as_deref(), Some(">=2.0.0")); | |
| 1840 | // Not a single requirement below the version: left alone. | |
| 1841 | assert_eq!(raised_requirement(">=1, <2", "2.0.0"), None); | |
| 1842 | assert_eq!(raised_requirement("1.*", "2.0.0"), None); | |
| 1843 | assert_eq!(raised_requirement("*", "2.0.0"), None); | |
| 1844 | assert_eq!(raised_requirement("2.1", "2.0.0"), None); | |
| 1845 | } | |
| 1846 | ||
| 1847 | #[test] | |
| 1848 | fn cargo_toml_requirements_are_raised_in_every_form() { | |
| 1849 | let text = r#"[package] | |
| 1850 | name = "app" | |
| 1851 | version = "0.1.0" | |
| 1852 | ||
| 1853 | [dependencies] | |
| 1854 | serde = "1.0" # data | |
| 1855 | tokio = { version = "~1.20", features = ["full"] } | |
| 1856 | "serde_json" = { version = "=1.0.100" } | |
| 1857 | other = { version = "0.1", package = "serde" } | |
| 1858 | shared = { workspace = true } | |
| 1859 | time = "0.1" | |
| 1860 | ||
| 1861 | [dev-dependencies.serde] | |
| 1862 | features = ["derive"] | |
| 1863 | version = "^1.0.150" | |
| 1864 | ||
| 1865 | [target.'cfg(unix)'.build-dependencies] | |
| 1866 | serde = { path = "../serde", version = ">=1.0" } | |
| 1867 | ||
| 1868 | [workspace.dependencies] | |
| 1869 | serde = '1' | |
| 1870 | ||
| 1871 | [[bin]] | |
| 1872 | name = "serde" | |
| 1873 | version = "0.1" | |
| 1874 | ||
| 1875 | [features] | |
| 1876 | serde = [] | |
| 1877 | "#; | |
| 1878 | let (out, moved) = rewrite_cargo_requirements(text, "serde", "1.0.200"); | |
| 1879 | assert_eq!(moved, 5); | |
| 1880 | assert!(out.contains("serde = \"1.0.200\" # data\n")); | |
| 1881 | assert!(out.contains("other = { version = \"1.0.200\", package = \"serde\" }\n")); | |
| 1882 | assert!(out.contains("[dev-dependencies.serde]\nfeatures = [\"derive\"]\nversion = \"^1.0.200\"\n")); | |
| 1883 | assert!(out.contains("serde = { path = \"../serde\", version = \">=1.0.200\" }\n")); | |
| 1884 | assert!(out.contains("serde = '1.0.200'\n")); | |
| 1885 | // The package's own version, a binary and a feature are not dependencies. | |
| 1886 | assert!(out.contains("[package]\nname = \"app\"\nversion = \"0.1.0\"\n")); | |
| 1887 | assert!(out.contains("[[bin]]\nname = \"serde\"\nversion = \"0.1\"\n")); | |
| 1888 | assert!(out.contains("time = \"0.1\"\n")); | |
| 1889 | ||
| 1890 | let (out, moved) = rewrite_cargo_requirements(text, "tokio", "1.37.0"); | |
| 1891 | assert_eq!(moved, 1); | |
| 1892 | assert!(out.contains("tokio = { version = \"~1.37.0\", features = [\"full\"] }\n")); | |
| 1893 | let (out, moved) = rewrite_cargo_requirements(text, "serde_json", "1.0.117"); | |
| 1894 | assert_eq!(moved, 1); | |
| 1895 | assert!(out.contains("\"serde_json\" = { version = \"=1.0.117\" }\n")); | |
| 1896 | // Already allowed, or not named: nothing changes. | |
| 1897 | let (same, moved) = rewrite_cargo_requirements(text, "serde", "0.0.5"); | |
| 1898 | assert_eq!((same.as_str(), moved), (text, 0)); | |
| 1899 | assert_eq!(rewrite_cargo_requirements(text, "rand", "0.9.0").1, 0); | |
| 1900 | // A renamed table section names its package. | |
| 1901 | let renamed = "[dependencies.json]\npackage = \"serde_json\"\nversion = \"1.0.0\"\n"; | |
| 1902 | assert_eq!(rewrite_cargo_requirements(renamed, "serde_json", "1.0.117").0, "[dependencies.json]\npackage = \"serde_json\"\nversion = \"1.0.117\"\n"); | |
| 1903 | assert_eq!(rewrite_cargo_requirements(renamed, "json", "1.0.117").1, 0); | |
| 1904 | } | |
| 1905 | ||
| 1906 | #[test] | |
| 1907 | fn workspace_members_by_path_or_trailing_star() { | |
| 1908 | let root: toml::Value = toml::from_str("[workspace]\nmembers = [\"crates/*\", \"./tools/xtask/\", \"apps/**\", \"../outside\", \"a?b\"]\n").unwrap(); | |
| 1909 | assert_eq!(workspace_members(&root), ["crates/*", "tools/xtask"]); | |
| 1910 | assert!(workspace_members(&toml::from_str::<toml::Value>("[package]\nname = \"x\"\n").unwrap()).is_empty()); | |
| 1911 | assert_eq!(header_keys("[target.'cfg(target_os = \"linux\")'.dependencies]"), Some(strings(&["target", "cfg(target_os = \"linux\")", "dependencies"]))); | |
| 1912 | assert_eq!(header_keys("[[bin]]"), None); | |
| 1913 | assert_eq!(dependency_table(&strings(&["workspace", "dependencies"])), Some(None)); | |
| 1914 | assert_eq!(dependency_table(&strings(&["dependencies", "serde"])), Some(Some("serde".into()))); | |
| 1915 | assert_eq!(dependency_table(&strings(&["package"])), None); | |
| 1916 | } | |
| 1917 | ||
| 1918 | #[test] | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1919 | fn poetry_adds_a_direct_dependency_and_updates_any_other() { |
| 1920 | let pyproject: toml::Value = toml::from_str( | |
| 1921 | "[tool.poetry.dependencies]\npython = \"^3.11\"\nRequests = \"^2.0\"\n\n[tool.poetry.group.test.dependencies]\npytest = \"^7\"\n", | |
| 1922 | ) | |
| 1923 | .unwrap(); | |
| 1924 | assert_eq!(poetry_group(&pyproject, "requests"), Some(None)); | |
| 1925 | assert_eq!(poetry_group(&pyproject, "pytest"), Some(Some("test".to_owned()))); | |
| 1926 | assert_eq!(poetry_group(&pyproject, "urllib3"), None); | |
| 1927 | let pep621: toml::Value = toml::from_str("[project]\ndependencies = [\"jinja2>=3.0\", \"Flask_Cors\"]\n").unwrap(); | |
| 1928 | assert_eq!(poetry_group(&pep621, "Jinja2"), Some(None)); | |
| 1929 | assert_eq!(poetry_group(&pep621, "flask-cors"), Some(None)); | |
| 1930 | ||
| 1931 | let poetry = strings(&["poetry"]); | |
| 1932 | assert_eq!(poetry_commands(&poetry, "requests", "2.31.0", Some(None)), strings(&["poetry", "add", "requests@^2.31.0", "--lock"])); | |
| 1933 | assert_eq!( | |
| 1934 | poetry_commands(&poetry, "pytest", "7.4.0", Some(Some("test".into()))), | |
| 1935 | strings(&["poetry", "add", "pytest@^7.4.0", "--lock", "--group", "test"]) | |
| 1936 | ); | |
| 1937 | assert_eq!(poetry_commands(&poetry, "urllib3", "2.0.7", None), strings(&["poetry", "update", "--lock", "urllib3"])); | |
| 1938 | } | |
| 1939 | ||
| 1940 | #[test] | |
| 1941 | fn requirements_pins_are_rewritten_in_place() { | |
| 1942 | 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"; | |
| 1943 | let (out, moved) = rewrite_pins(text, "requests", "2.31.0"); | |
| 1944 | assert_eq!(moved, 1); | |
| 1945 | assert!(out.contains("requests==2.31.0 # http\n")); | |
| 1946 | assert!(out.contains("requests_toolbelt==0.9.1\n")); | |
| 1947 | let (out, moved) = rewrite_pins(&out, "django", "3.2.25"); | |
| 1948 | assert_eq!(moved, 1); | |
| 1949 | assert!(out.contains("Django[argon2]===3.2.25 ; python_version >= \"3.8\"\n")); | |
| 1950 | // Already at or past the version: left alone. | |
| 1951 | let (same, moved) = rewrite_pins(text, "urllib3", "1.26.18"); | |
| 1952 | assert_eq!((same.as_str(), moved), (text, 0)); | |
| 1953 | // A line without a final newline keeps it that way. | |
| 1954 | assert_eq!(rewrite_pins("Requests==2.0", "requests", "2.31.0").0, "Requests==2.31.0"); | |
| 1955 | assert_eq!(rewrite_pins("requests == 2.0,<3\n", "requests", "2.31.0").0, "requests == 2.31.0,<3\n"); | |
| 1956 | } | |
| 1957 | ||
| 1958 | #[test] | |
| 1959 | fn lockfiles_are_read_again_for_what_is_still_below() { | |
| 1960 | let lock = r#"{"lockfileVersion":3,"packages":{"":{},"node_modules/lodash":{"version":"4.17.21"},"node_modules/a/node_modules/lodash":{"version":"4.17.4"}}}"#; | |
| 1961 | assert_eq!(below(Lockfile::PackageLock, lock, Ecosystem::Npm, "lodash", "4.17.21"), ["4.17.4"]); | |
| 1962 | let sum = "golang.org/x/net v0.17.0 h1:x=\ngolang.org/x/net v0.23.0 h1:y=\n"; | |
| 1963 | assert!(below(Lockfile::GoSum, sum, Ecosystem::Go, "golang.org/x/net", "v0.23.0").is_empty()); | |
| 1964 | assert_eq!(below(Lockfile::Requirements, "Requests==2.0\n", Ecosystem::PyPI, "requests", "2.31.0"), ["2.0"]); | |
| 1965 | } | |
| 1966 | ||
| 1967 | #[test] | |
| 1968 | fn a_failure_says_its_last_lines() { | |
| 1969 | assert_eq!(tail("a\n\nb\nc\n", 2), "b\nc"); | |
| 1970 | assert_eq!(tail("only", 5), "only"); | |
| 1971 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1972 | |
| 1973 | fn registry(kind: &str, url: &str) -> Registry { | |
| 1974 | Registry { kind: kind.into(), url: url.into(), ..Registry::default() } | |
| 1975 | } | |
| 1976 | ||
| 1977 | fn env_of(tools: &Tools) -> Vec<(&str, &str)> { | |
| 1978 | tools.env.iter().map(|(name, value)| (name.as_str(), value.as_str())).collect() | |
| 1979 | } | |
| 1980 | ||
| 1981 | #[test] | |
| 1982 | fn registries_arrive_as_the_contract_writes_them() { | |
| 1983 | let text = r#"[{"type":"npm-registry","url":"https://npm.acme.dev","token":"t0k","replacesBase":true,"scopes":["@acme"]}]"#; | |
| 1984 | let registries: Vec<Registry> = serde_json::from_str(text).unwrap(); | |
| 1985 | assert_eq!(registries[0].kind, "npm-registry"); | |
| 1986 | assert!(registries[0].replaces_base); | |
| 1987 | assert_eq!(registries[0].scopes, ["@acme"]); | |
| 1988 | // Its debug form never holds the token. | |
| 1989 | assert!(!format!("{:?}", registries[0]).contains("t0k")); | |
| 1990 | } | |
| 1991 | ||
| 1992 | #[test] | |
| 1993 | fn npm_registries_become_a_user_npmrc() { | |
| 1994 | let dir = Path::new("/tmp/g1t-registries"); | |
| 1995 | let mut replacing = registry("npm-registry", "https://npm.acme.dev/"); | |
| 1996 | replacing.token = Some("t0k".into()); | |
| 1997 | replacing.replaces_base = true; | |
| 1998 | let mut scoped = registry("npm-registry", "https://pkgs.acme.dev/npm/"); | |
| 1999 | scoped.username = Some("ada".into()); | |
| 2000 | scoped.password = Some("p:w".into()); | |
| 2001 | scoped.scopes = strings(&["@acme", "@tools"]); | |
| 2002 | let tools = registry_tools(&[replacing, scoped], &Named::default(), dir).unwrap(); | |
| 2003 | assert_eq!(tools.files.len(), 1); | |
| 2004 | assert_eq!(tools.files[0].0, dir.join("npmrc")); | |
| 2005 | assert_eq!( | |
| 2006 | tools.files[0].1, | |
| 2007 | "//npm.acme.dev/:_authToken=t0k\nregistry=https://npm.acme.dev/\n//pkgs.acme.dev/npm/:_auth=YWRhOnA6dw==\n@acme:registry=https://pkgs.acme.dev/npm/\n@tools:registry=https://pkgs.acme.dev/npm/\n" | |
| 2008 | ); | |
| 2009 | assert_eq!( | |
| 2010 | env_of(&tools), | |
| 2011 | [ | |
| 2012 | ("YARN_NPM_REGISTRY_SERVER", "https://npm.acme.dev/"), | |
| 2013 | ("YARN_NPM_AUTH_TOKEN", "t0k"), | |
| 2014 | ("YARN_NPM_ALWAYS_AUTH", "true"), | |
| 2015 | ("NPM_CONFIG_USERCONFIG", dir.join("npmrc").display().to_string().as_str()), | |
| 2016 | ] | |
| 2017 | ); | |
| 2018 | assert!(registry_tools(&[], &Named::default(), dir).unwrap().env.is_empty()); | |
| 2019 | } | |
| 2020 | ||
| 2021 | #[test] | |
| 2022 | fn cargo_registries_by_the_projects_names_or_as_crates_io() { | |
| 2023 | let dir = Path::new("/tmp/g1t-registries"); | |
| 2024 | let config = "[registries.acme]\nindex = \"sparse+https://cargo.acme.dev/index/\"\n[registries.other]\nindex = \"https://elsewhere.dev/git\"\n"; | |
| 2025 | let named = Named { cargo: cargo_registries(config), poetry: Vec::new() }; | |
| 2026 | assert_eq!(named.cargo.len(), 2); | |
| 2027 | let mut acme = registry("cargo-registry", "https://cargo.acme.dev/index"); | |
| 2028 | acme.token = Some("ct".into()); | |
| 2029 | let tools = registry_tools(std::slice::from_ref(&acme), &named, dir).unwrap(); | |
| 2030 | assert_eq!(env_of(&tools), [("CARGO_REGISTRIES_ACME_TOKEN", "ct")]); | |
| 2031 | assert!(tools.cargo_config.is_none()); | |
| 2032 | ||
| 2033 | acme.replaces_base = true; | |
| 2034 | let tools = registry_tools(&[acme], &Named::default(), dir).unwrap(); | |
| 2035 | assert_eq!(env_of(&tools), [("CARGO_REGISTRIES_G1T_PRIVATE_TOKEN", "ct")]); | |
| 2036 | assert_eq!(tools.cargo_config.as_deref(), Some(dir.join("cargo.toml").display().to_string().as_str())); | |
| 2037 | let written: toml::Value = toml::from_str(&tools.files[0].1).unwrap(); | |
| 2038 | assert_eq!(written["source"]["crates-io"]["replace-with"].as_str(), Some("g1t-private")); | |
| 2039 | assert_eq!(written["source"]["g1t-private"]["registry"].as_str(), Some("sparse+https://cargo.acme.dev/index/")); | |
| 2040 | assert_eq!(written["registries"]["g1t-private"]["index"].as_str(), Some("sparse+https://cargo.acme.dev/index/")); | |
| 2041 | } | |
| 2042 | ||
| 2043 | #[test] | |
| 2044 | fn python_indexes_carry_their_credentials_in_the_url() { | |
| 2045 | let dir = Path::new("/tmp/g1t-registries"); | |
| 2046 | let mut base = registry("python-index", "https://pypi.acme.dev/simple/"); | |
| 2047 | base.username = Some("ada@acme".into()); | |
| 2048 | base.password = Some("p w/1".into()); | |
| 2049 | base.replaces_base = true; | |
| 2050 | let mut extra = registry("python-index", "https://extra.acme.dev/simple"); | |
| 2051 | extra.token = Some("tok".into()); | |
| 2052 | let named = Named { cargo: Vec::new(), poetry: poetry_sources("[[tool.poetry.source]]\nname = \"acme-extra\"\nurl = \"https://extra.acme.dev/simple/\"\n") }; | |
| 2053 | let tools = registry_tools(&[base, extra, registry("python-index", "https://open.acme.dev/simple")], &named, dir).unwrap(); | |
| 2054 | assert_eq!( | |
| 2055 | env_of(&tools), | |
| 2056 | [ | |
| 2057 | ("PIP_INDEX_URL", "https://ada%40acme:p%20w%2F1@pypi.acme.dev/simple/"), | |
| 2058 | ("POETRY_HTTP_BASIC_ACME_EXTRA_USERNAME", "__token__"), | |
| 2059 | ("POETRY_HTTP_BASIC_ACME_EXTRA_PASSWORD", "tok"), | |
| 2060 | ("PIP_EXTRA_INDEX_URL", "https://__token__:tok@extra.acme.dev/simple https://open.acme.dev/simple"), | |
| 2061 | ] | |
| 2062 | ); | |
| 2063 | assert!(tools.files.is_empty()); | |
| 2064 | } | |
| 2065 | ||
| 2066 | #[test] | |
| 2067 | fn go_proxies_come_first_with_a_netrc() { | |
| 2068 | let dir = Path::new("/tmp/g1t-registries"); | |
| 2069 | let mut proxy = registry("goproxy-server", "https://goproxy.acme.dev/mod"); | |
| 2070 | proxy.username = Some("ada".into()); | |
| 2071 | proxy.password = Some("pw".into()); | |
| 2072 | let tools = registry_tools(std::slice::from_ref(&proxy), &Named::default(), dir).unwrap(); | |
| 2073 | assert_eq!( | |
| 2074 | env_of(&tools), | |
| 2075 | [("GOPROXY", "https://goproxy.acme.dev/mod,https://proxy.golang.org,direct"), ("NETRC", dir.join("netrc").display().to_string().as_str())] | |
| 2076 | ); | |
| 2077 | assert_eq!(tools.files, [(dir.join("netrc"), "machine goproxy.acme.dev\nlogin ada\npassword pw\n".to_owned())]); | |
| 2078 | proxy.replaces_base = true; | |
| 2079 | let tools = registry_tools(&[proxy], &Named::default(), dir).unwrap(); | |
| 2080 | assert_eq!(tools.get("GOPROXY"), Some("https://goproxy.acme.dev/mod,direct")); | |
| 2081 | } | |
| 2082 | ||
| 2083 | #[test] | |
| 2084 | fn registries_are_checked_before_anything_is_written() { | |
| 2085 | let dir = Path::new("/tmp/g1t-registries"); | |
| 2086 | let check = |registry: Registry| registry_tools(&[registry], &Named::default(), dir).map(|_| ()); | |
| 2087 | assert!(check(registry("maven-repository", "https://m.acme.dev")).is_err()); | |
| 2088 | assert!(check(registry("npm-registry", "http://npm.acme.dev")).is_err()); | |
| 2089 | assert!(check(registry("npm-registry", "https://")).is_err()); | |
| 2090 | assert!(check(registry("goproxy-server", "https://a.dev,https://evil.dev")).is_err()); | |
| 2091 | let mut newline = registry("npm-registry", "https://npm.acme.dev"); | |
| 2092 | newline.token = Some("t\nregistry=https://evil.dev".into()); | |
| 2093 | assert!(check(newline).is_err()); | |
| 2094 | let mut spaced = registry("goproxy-server", "https://goproxy.acme.dev"); | |
| 2095 | spaced.password = Some("a b".into()); | |
| 2096 | assert!(check(spaced).is_err()); | |
| 2097 | let mut scope = registry("npm-registry", "https://npm.acme.dev"); | |
| 2098 | scope.scopes = strings(&["acme"]); | |
| 2099 | assert!(check(scope).is_err()); | |
| 2100 | } | |
| 2101 | ||
| 2102 | #[test] | |
| 2103 | fn registry_urls_compare_without_protocol_or_slash() { | |
| 2104 | assert!(same_registry("sparse+https://cargo.acme.dev/index/", "https://Cargo.acme.dev/index")); | |
| 2105 | assert!(same_registry("https://git.acme.dev/index.git", "https://git.acme.dev/index")); | |
| 2106 | assert!(!same_registry("https://a.dev/x", "https://a.dev/y")); | |
| 2107 | assert_eq!(npm_nerf_dart("https://npm.acme.dev"), "//npm.acme.dev/"); | |
| 2108 | assert_eq!(url_host("https://goproxy.acme.dev:8443/mod"), "goproxy.acme.dev"); | |
| 2109 | assert_eq!(env_part("acme-extra.v2"), "ACME_EXTRA_V2"); | |
| 2110 | assert_eq!(percent_encode("a@b:c/d e"), "a%40b%3Ac%2Fd%20e"); | |
| 2111 | } | |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 2112 | } |