upgrade.rs
Rust · 525 lines · 22.6 KB
//! `brain upgrade`: update this install from the git clone it was built from. Fetches the clone's
//! upstream, shows what's new, fast-forwards, and rebuilds with the same features into the same
//! place. The build runs from a clean checkout of the exact fetched commit (never from loose files
//! in the folder), only https and ssh remotes are used, and a token embedded in the remote URL is
//! moved into git's credential store once it is verified to be retrievable there.
use crate::ui;
use anyhow::{anyhow, bail, Context, Result};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// The clone this binary was built from. `brain upgrade` builds from a temporary checkout and
/// passes the real folder through BRAIN_SOURCE_DIR.
fn built_from() -> PathBuf {
PathBuf::from(option_env!("BRAIN_SOURCE_DIR").unwrap_or(env!("CARGO_MANIFEST_DIR")))
}
/// The commit this binary was built from, when `brain upgrade` built it.
const BUILT_COMMIT: Option<&str> = option_env!("BRAIN_BUILD_COMMIT");
#[derive(clap::Args, Debug, Default)]
pub struct UpgradeArgs {
/// Only check whether an update is available
#[arg(long)]
check: bool,
/// Don't ask before updating (needed when not run from a terminal)
#[arg(long, short)]
yes: bool,
/// The clone to update (default: the folder this binary was built from)
#[arg(long)]
source: Option<PathBuf>,
}
fn git(dir: &Path) -> Command {
let mut c = Command::new("git");
c.arg("-C").arg(dir);
// No git hooks: a hook in the fetched commit (core.hooksPath inside the repo) or a corporate one
// would run before anything is reviewed, and could hang.
c.arg("-c").arg(format!("core.hooksPath={}", crate::store::app_cache_dir().join("no-hooks").display()));
// Only https and ssh, even if a url.*.insteadOf rule rewrites the remote to something else.
c.env("GIT_ALLOW_PROTOCOL", "https:ssh");
if !std::io::stdin().is_terminal() {
c.env("GIT_TERMINAL_PROMPT", "0"); // never hang on a prompt nobody can answer
}
c
}
fn out(dir: &Path, args: &[&str]) -> Result<String> {
let o = git(dir).args(args).output().with_context(|| format!("running git {}", args.join(" ")))?;
if !o.status.success() {
bail!("git {} failed: {}", args.first().unwrap_or(&""), String::from_utf8_lossy(&o.stderr).trim());
}
Ok(String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn pct_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'%'
&& i + 2 < b.len()
&& let Ok(v) = u8::from_str_radix(std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("zz"), 16)
{
out.push(v);
i += 3;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8_lossy(&out).into_owned()
}
/// Credentials embedded in a remote URL.
#[derive(Debug, PartialEq)]
pub(crate) struct UrlCreds {
pub clean: String,
pub host: String,
pub path: String,
pub user: String,
pub secret: String,
}
/// Split `https://USER:TOKEN@host/path` (userinfo percent-decoded) into its parts.
pub(crate) fn split_userinfo(url: &str) -> Option<UrlCreds> {
let rest = url.strip_prefix("https://")?;
let (authority, path) = rest.split_once('/').map(|(a, p)| (a, p.to_string())).unwrap_or((rest, String::new()));
let (auth, host) = authority.rsplit_once('@')?;
let (user, secret) = auth.split_once(':')?;
let (user, secret) = (pct_decode(user), pct_decode(secret));
let bad = |s: &str| s.is_empty() || s.chars().any(|c| c.is_control());
if bad(&user) || bad(&secret) || host.is_empty() {
return None;
}
Some(UrlCreds {
clean: format!("https://{host}/{path}").trim_end_matches('/').to_string(),
host: host.to_string(),
path,
user,
secret,
})
}
/// Remote URLs upgrade will fetch from: https://, ssh://, or scp-style `user@host:path`.
pub(crate) fn allowed_remote(url: &str) -> bool {
if url.contains(char::is_whitespace) || url.chars().any(|c| c.is_control()) {
return false;
}
if let Some(rest) = url.strip_prefix("https://").or_else(|| url.strip_prefix("ssh://")) {
// The host (after any user@) must be a plain name: nothing that ssh could read as an option.
let authority = rest.split('/').next().unwrap_or("");
let host = authority.rsplit_once('@').map(|(_, h)| h).unwrap_or(authority);
return !host.is_empty() && !host.starts_with('-') && !authority.starts_with('-');
}
// scp-style: user@host:path, with no scheme, no spaces, and a plain host name.
match url.split_once('@').and_then(|(u, rest)| rest.split_once(':').map(|(h, p)| (u, h, p))) {
Some((u, h, p)) => {
!u.is_empty()
&& u.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c))
&& !h.is_empty()
&& h.chars().all(|c| c.is_ascii_alphanumeric() || ".-".contains(c))
&& !p.is_empty()
&& !p.starts_with('-')
&& !url.contains(char::is_whitespace)
}
None => false,
}
}
fn credential_input(c: &UrlCreds, with_password: bool) -> String {
let mut s = format!("protocol=https\nhost={}\npath={}\nusername={}\n", c.host, c.path, c.user);
if with_password {
s.push_str(&format!("password={}\n", c.secret)); // leak-scan: allow (a format string, not a secret)
}
s.push('\n');
s
}
/// `git credential <action>` through exactly these helpers (the configured list is reset first).
fn credential(src: &Path, helpers: &[String], action: &str, input: &str) -> Result<String> {
let mut cmd = git(src);
cmd.env("GIT_TERMINAL_PROMPT", "0").args(["-c", "credential.helper="]);
for h in helpers {
cmd.arg("-c").arg(format!("credential.helper={h}"));
}
let mut child = cmd
.args(["credential", action])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
child.stdin.take().ok_or_else(|| anyhow!("no stdin for git credential"))?.write_all(input.as_bytes())?;
let o = child.wait_with_output()?;
Ok(String::from_utf8_lossy(&o.stdout).to_string())
}
/// A remote URL with `user:token@` in it keeps the token in plain text in `.git/config`. Store the
/// token with git's credential helper, read it back to be sure the helper really keeps it, and only
/// then drop it from the URL. Without a working helper the URL is left alone (the token is not
/// thrown away).
fn clean_remote(src: &Path, a: &Asker) -> Result<()> {
let url = out(src, &["config", "--get", "remote.origin.url"])?;
let Some(c) = split_userinfo(&url) else { return Ok(()) };
println!(
" {} the remote address has your token in it (plain text in {}).",
ui::yellow("⚠"),
src.join(".git").join("config").display()
);
// The helpers git would really use: an empty `credential.helper` value resets the list.
let mut helpers: Vec<String> = vec![];
for v in out(src, &["config", "--get-all", "credential.helper"]).unwrap_or_default().lines() {
if v.trim().is_empty() {
helpers.clear();
} else {
helpers.push(v.trim().to_string());
}
}
let url_helper = out(src, &["config", "--get-urlmatch", "credential.helper", &c.clean]).unwrap_or_default();
if !url_helper.trim().is_empty() && !helpers.contains(&url_helper.trim().to_string()) {
helpers.push(url_helper.trim().to_string());
}
// `cache` only remembers for a few minutes: the token must go to (and be read back from) a
// lasting helper, with `cache` left out of the check so it can't vouch for itself.
let durable: Vec<String> = helpers.iter().filter(|h| h.split_whitespace().next() != Some("cache")).cloned().collect();
let helper = durable.last().cloned().unwrap_or_default();
if !helpers.is_empty() && durable.is_empty() {
println!(
" Your only credential helper is `cache`, which forgets after a few minutes, so the token stays \
in the address. Set a lasting one (manager / osxkeychain / libsecret), then run upgrade again."
);
return Ok(());
}
if helper.is_empty() {
println!(
" No git credential helper is set, so it stays there for now. Set one, then run upgrade again: \
Windows and macOS have one (`git config --global credential.helper manager` / `osxkeychain`); \
Linux: `git config --global credential.helper libsecret`."
);
return Ok(());
}
if helper.split_whitespace().next() == Some("store") {
println!(" {}", ui::dim("Note: the `store` helper also keeps it in plain text (~/.git-credentials)."));
}
if !a.confirm(" Move it to your credential store and clean the address?", true)? {
return Ok(());
}
credential(src, &durable, "approve", &credential_input(&c, true))?;
let back = credential(src, &durable, "fill", &credential_input(&c, false))?;
if !back.lines().any(|l| l.strip_prefix("password=") == Some(c.secret.as_str())) {
println!(
" {} the credential helper ({helper}) didn't keep the token, so the address was left as it is.",
ui::yellow("⚠")
);
return Ok(());
}
out(src, &["remote", "set-url", "origin", &c.clean])?;
println!(" {} token moved to your credential store ({helper}); address cleaned", ui::green("✓"));
Ok(())
}
struct Asker {
yes: bool,
}
impl Asker {
fn confirm(&self, q: &str, default: bool) -> Result<bool> {
if self.yes {
return Ok(true);
}
if !std::io::stdin().is_terminal() {
bail!("not a terminal: pass --yes to upgrade without asking");
}
print!("{q} ({}) ", if default { "Y/n" } else { "y/N" });
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
let a = line.trim().to_lowercase();
Ok(if a.is_empty() { default } else { a.starts_with('y') })
}
}
/// `version = "x.y.z"` from a Cargo.toml text.
fn cargo_version(toml_text: &str) -> Option<String> {
toml_text
.lines()
.find_map(|l| l.trim().strip_prefix("version = \"").and_then(|v| v.strip_suffix('"')))
.map(str::to_string)
}
/// Changelog sections that are in `new` but not in `old`, matched by version (a heading may gain
/// a date later: "## 0.2.0" → "## 0.2.0 (…)").
pub(crate) fn new_sections(old: &str, new: &str) -> Vec<String> {
let ver = |h: &str| h.trim_start_matches("## ").split_whitespace().next().unwrap_or("").to_string();
let mut out = vec![];
let mut cur = String::new();
for line in new.lines().chain(std::iter::once("## ")) {
if line.starts_with("## ") {
let heading = cur.lines().next().unwrap_or("").to_string();
if !heading.is_empty() && !old.lines().any(|l| l.starts_with("## ") && ver(l) == ver(&heading)) {
out.push(cur.trim_end().to_string());
}
cur = String::new();
}
if !cur.is_empty() || (line.starts_with("## ") && line.len() > 3) {
cur.push_str(line);
cur.push('\n');
}
}
out
}
/// Where cargo should install so the binary that is running gets replaced: the install root is
/// the folder above `bin/`.
fn install_root() -> Option<PathBuf> {
let me = std::env::current_exe().ok()?.canonicalize().ok()?;
let bin = me.parent()?;
if bin.file_name()? == "bin" { bin.parent().map(Path::to_path_buf) } else { None }
}
/// Removes a folder however the function ends.
struct RemoveDir(PathBuf);
impl Drop for RemoveDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Put the tested binary in place of the installed one. Nothing is touched until the new binary
/// has been built and run, and the old one comes back if the swap fails (Windows can't overwrite a
/// running .exe, only rename it, so there the old one is moved aside first).
fn swap_in(staged: &Path, target: &Path) -> Result<()> {
let dir = target.parent().ok_or_else(|| anyhow!("no folder for {}", target.display()))?;
std::fs::create_dir_all(dir)?;
let incoming = dir.join(if cfg!(windows) { "brain.new.exe" } else { ".brain.new" });
std::fs::copy(staged, &incoming).with_context(|| format!("copying the new binary into {}", dir.display()))?;
if cfg!(windows) && target.exists() {
let old = target.with_extension("old.exe");
let _ = std::fs::remove_file(&old);
std::fs::rename(target, &old).with_context(|| format!("moving {} aside", target.display()))?;
if let Err(e) = std::fs::rename(&incoming, target) {
let _ = std::fs::rename(&old, target);
let _ = std::fs::remove_file(&incoming);
bail!("couldn't put the new binary in place ({e}); the previous version is back");
}
} else if let Err(e) = std::fs::rename(&incoming, target) {
// Same folder, so this is an atomic replace on macOS/Linux: the old binary stays until it succeeds.
let _ = std::fs::remove_file(&incoming);
bail!("couldn't put the new binary in place ({e}); the previous version is unchanged");
}
Ok(())
}
/// Removes the temporary checkout however the build ends.
struct Worktree<'a> {
src: &'a Path,
dir: PathBuf,
}
impl Drop for Worktree<'_> {
fn drop(&mut self) {
let _ = git(self.src).args(["worktree", "remove", "--force"]).arg(&self.dir).output();
}
}
/// Build `commit` from a clean temporary checkout and install it in place of this binary.
fn build(src: &Path, commit: &str) -> Result<()> {
// A private folder (not the shared temp dir, where another user could plant a .cargo/config).
let base = crate::store::app_cache_dir();
crate::store::create_private_dir(&base);
let dir = base.join(format!("upgrade-{}", std::process::id()));
let _ = git(src).args(["worktree", "remove", "--force"]).arg(&dir).output();
out(src, &["worktree", "add", "--detach", "--quiet", &dir.to_string_lossy(), commit])?;
let _tree = Worktree { src, dir: dir.clone() };
// Build into a private staging root, run the result, and only then swap it in: an
// interrupted or failed build never touches the installed binary.
let stage = base.join(format!("stage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&stage);
let _stage = RemoveDir(stage.clone());
let exe_name = if cfg!(windows) { "brain.exe" } else { "brain" };
let target = match install_root() {
Some(r) => r.join("bin").join(exe_name),
None => {
let root = std::env::var_os("CARGO_INSTALL_ROOT")
.or_else(|| std::env::var_os("CARGO_HOME"))
.map(PathBuf::from)
.unwrap_or_else(|| crate::store::home_dir().join(".cargo"));
println!(" {}", ui::dim(&format!("(this binary isn't in a cargo bin folder; installing into {})", root.join("bin").display())));
root.join("bin").join(exe_name)
}
};
let mut cargo = Command::new("cargo");
cargo
.args(["install", "--locked", "--force", "--bin", "brain", "--path"])
.arg(&dir)
.current_dir(&dir)
.arg("--target-dir")
.arg(src.join("target"))
.arg("--root")
.arg(&stage)
.env("BRAIN_SOURCE_DIR", src)
.env("BRAIN_BUILD_COMMIT", commit);
if cfg!(feature = "local-embeddings") {
cargo.args(["--features", "local-embeddings"]);
}
println!("\n building (a minute or two)…");
let status = cargo.stderr(Stdio::inherit()).status().context("running cargo (is Rust installed and on PATH?)")?;
if !status.success() {
bail!("the build failed; your previous version is untouched");
}
let staged = stage.join("bin").join(exe_name);
let v = Command::new(&staged).arg("--version").output().ok().filter(|o| o.status.success());
let Some(v) = v else { bail!("the new binary didn't run; your previous version is untouched") };
swap_in(&staged, &target)?;
println!("\n {} installed {} at {}", ui::green("✓"), String::from_utf8_lossy(&v.stdout).trim(), target.display());
Ok(())
}
pub fn run(args: UpgradeArgs) -> Result<()> {
let a = Asker { yes: args.yes };
let src = args.source.clone().unwrap_or_else(built_from);
// Absolute, because the build runs from a different folder and the binary remembers this path.
let src = src.canonicalize().unwrap_or(src);
println!("{} {}", ui::wordmark("upgrade"), ui::dim(&format!("{} · source {}", env!("CARGO_PKG_VERSION"), src.display())));
if !src.join("Cargo.toml").is_file() || !src.join(".git").exists() {
bail!(
"the source folder \"{}\" is gone or isn't a git clone. Pass the folder you cloned with --source, \
or clone again and run `cargo install --locked --path .` there",
src.display()
);
}
if cfg!(windows)
&& let Ok(me) = std::env::current_exe()
{
let _ = std::fs::remove_file(me.with_extension("old.exe")); // left by an earlier upgrade
}
let Ok(url) = out(&src, &["config", "--get", "remote.origin.url"]) else {
println!(" This is a development copy (no `origin` remote): there is nothing to download.");
return Ok(());
};
if !allowed_remote(&url) {
bail!("origin isn't an https or ssh address, so upgrade won't fetch from it");
}
if !args.check {
clean_remote(&src, &a)?;
}
// Follow the branch's upstream (origin/<branch>), not a guessed name.
let upstream = out(&src, &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]).map_err(|_| {
anyhow!(
"this checkout isn't on a branch that follows origin (detached HEAD?). Switch back with \
`git -C \"{}\" switch -` or `git -C \"{}\" branch -u origin/<branch>`",
src.display(),
src.display()
)
})?;
let Some((remote, _)) = upstream.split_once('/').filter(|(r, _)| *r == "origin") else {
bail!("the branch follows {upstream}, not origin; upgrade only uses origin");
};
print!(" checking for updates… ");
std::io::stdout().flush()?;
if !git(&src).args(["fetch", "--quiet", remote]).status()?.success() {
println!();
bail!("couldn't fetch the update (network, or your token: try `git -C \"{}\" fetch`)", src.display());
}
let target = out(&src, &["rev-parse", "--verify", &format!("{upstream}^{{commit}}")])?;
let head = out(&src, &["rev-parse", "HEAD"])?;
let behind: usize = out(&src, &["rev-list", "--count", &format!("HEAD..{target}")])?.parse().unwrap_or(0);
let running = env!("CARGO_PKG_VERSION");
if behind == 0 {
// Up to date with the remote, but this binary may be older than the folder (e.g. an
// earlier build failed after the update was fetched).
let stale_binary = match BUILT_COMMIT {
Some(c) => c != head,
None => std::fs::read_to_string(src.join("Cargo.toml"))
.ok()
.and_then(|t| cargo_version(&t))
.is_some_and(|v| v != running),
};
if !stale_binary {
println!("{}", ui::green("✓ up to date"));
return Ok(());
}
if head != target {
// Local commits on top of the download: never build those without showing them.
println!("{}", ui::green("✓ nothing new to download"));
println!(" {}", ui::dim("this clone has commits of its own, so upgrade won't rebuild it; use `cargo install --locked --path .` there"));
return Ok(());
}
println!("{}", ui::yellow("the source is newer than this binary"));
if args.check || !a.confirm(" Rebuild from it?", true)? {
return Ok(());
}
build(&src, &target)?;
next_steps();
return Ok(());
}
let available = out(&src, &["show", &format!("{target}:Cargo.toml")]).ok().and_then(|t| cargo_version(&t));
println!("{}", ui::bold(&clean(&format!("{running} → {}", available.unwrap_or_else(|| format!("{behind} new commit(s)"))))));
let old_log = std::fs::read_to_string(src.join("CHANGELOG.md")).unwrap_or_default();
let new_log = out(&src, &["show", &format!("{target}:CHANGELOG.md")]).unwrap_or_default();
let sections = new_sections(&old_log, &new_log);
if sections.is_empty() {
println!("\n {}", ui::heading("Changes"));
for l in out(&src, &["log", "--format=%s", &format!("HEAD..{target}")])?.lines().take(20) {
println!(" - {}", clean(l));
}
}
for section in sections {
println!();
for (i, l) in section.lines().enumerate() {
if i == 0 {
println!(" {}", ui::heading(&clean(l.trim_start_matches("## "))));
} else if l.trim().is_empty() {
println!();
} else {
println!(" {}", clean(l));
}
}
}
if args.check {
println!("\n Run {} to install it.", ui::bold("brain upgrade"));
return Ok(());
}
if !out(&src, &["status", "--porcelain", "--untracked-files=no"])?.is_empty() {
bail!("\"{}\" has local changes; commit or stash them first (upgrade never overwrites your work)", src.display());
}
if !a.confirm("\n Install it?", true)? {
return Ok(());
}
let ahead: usize = out(&src, &["rev-list", "--count", &format!("{target}..HEAD")])?.parse().unwrap_or(0);
if ahead > 0 {
bail!(
"this clone has {ahead} commit(s) of its own that the download doesn't, so it can't simply move forward. \
Keep them on a branch or clone the download again into a new folder"
);
}
// Build and install first; move the folder forward only once the new binary works, so a
// failed build leaves both the binary and the folder as they were (and the next run retries).
build(&src, &target)?;
if let Err(e) = out(&src, &["merge", "--ff-only", "--quiet", &target]) {
println!(
" {} installed, but the folder couldn't be moved forward ({e:#}); run `git -C \"{}\" merge --ff-only {upstream}`",
ui::yellow("⚠"),
src.display()
);
}
next_steps();
Ok(())
}
/// Text from the download (changelog, commit subjects) shown in the terminal: no control characters,
/// so it can't move the cursor or change colours.
fn clean(s: &str) -> String {
s.chars().map(|c| if c.is_control() && c != '\t' { ' ' } else { c }).collect()
}
fn next_steps() {
println!(" Next: {} to refresh the agent instructions, then {}.", ui::bold("brain setup"), ui::bold("brain doctor"));
}