bakery: add search, completions, rollback, verify, purge, dry-run, self-update

New CLI surface, approved for review before merge:

- search <query>: case-insensitive name/description substring match
- completions <shell>: bash/zsh/fish/elvish/powershell via clap_complete
- rollback <pkg>: restore the previously installed version from a local
  pre-update binary backup (not a network re-fetch — index.json's minisign
  signature only covers the current published version, so pinning an old
  version from the server would only be checkable against its unsigned
  per-version .sha256 sidecar, a materially weaker trust path)
- verify [pkg]: recompute installed binaries' sha256 and compare against
  the hash recorded at install time, not a fresh index lookup (the index
  only has the latest release's checksum, which may not match what's
  actually installed)
- remove --purge: additionally remove the license dir, desktop entry, and
  data dir, each gated through the existing confirm() prompt; config is
  still deliberately left alone
- self-update: documented entry point for updating bakery itself
- --dry-run: global flag, short-circuits right before install::
  install_package in both the install and update paths
- download progress: chunked read loop in manifest::fetch_bytes prints
  periodic \r progress on stderr when Content-Length is present and stderr
  is a tty
- update --all output: "already at X" is now DIM with a neutral glyph
  instead of GREEN, plus a bold one-line summary count, so unchanged
  packages don't visually compete with ones that actually changed

InstalledPackage gained previous_version and binary_sha256 (both
#[serde(default)]) to back rollback/verify. fetch_and_place now returns the
verified sha256 instead of discarding it.

Also fixes a handful of pre-existing clippy lints in files this touches
(manual split_once, &PathBuf-vs-&Path, derivable Default, unnecessary
unwrap) surfaced by a clippy version newer than when that code was last
touched — confirmed via git stash that they predate this branch. bread-
utils has one more of these (suspicious_open_options in singleton.rs) left
alone: the mechanical fix would truncate the PID file before the
lock-held-by-another-process branch reads its contents, which would break
toggle_or_kill's PID lookup, so cargo clippy -p bakery needs --no-deps
until that one's fixed with actual thought.
This commit is contained in:
Breadway 2026-08-05 17:10:31 +08:00
parent d45fc422f2
commit eba8cb6c44
9 changed files with 785 additions and 69 deletions

View file

@ -212,8 +212,8 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
}
fn read_and_verify_cache(
cache_path: &PathBuf,
sig_cache_path: &PathBuf,
cache_path: &Path,
sig_cache_path: &Path,
track: Track,
) -> Result<Index> {
let bytes = std::fs::read(cache_path).context("reading cached index")?;
@ -225,14 +225,14 @@ fn read_and_verify_cache(
serde_json::from_slice(&bytes).context("parsing cached index")
}
fn cache_is_fresh(path: &PathBuf) -> bool {
fn cache_is_fresh(path: &Path) -> bool {
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE)
.unwrap_or(false)
}
fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track) -> Result<Index> {
fn fetch_and_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> Result<Index> {
let bytes = fetch_bytes(&primary_url(track)).with_context(|| {
format!(
"fetching {track} index — has a {track} build been published yet? \
@ -297,8 +297,14 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
/// gets buffered into memory before any trust check runs on it.
const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
/// How often (at most) the `\r`-overwritten progress line refreshes — a
/// LAN-speed download can push way more than one chunk per 100ms, and
/// printing on every chunk would flood the terminal instead of reassuring it.
const PROGRESS_THROTTLE: Duration = Duration::from_millis(100);
const CHUNK_SIZE: usize = 64 * 1024;
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
use std::io::Read;
use std::io::{IsTerminal, Read};
let resp = ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("{e}"))?;
@ -306,17 +312,51 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
if status != 200 {
bail!("HTTP {status} from {url}");
}
// Progress feedback only when there's a Content-Length to show progress
// against and stderr is an actual terminal — a multi-MB binary with no
// feedback at all looks like a hang, but piped/CI output shouldn't get
// `\r` noise. A manual chunked read loop (instead of one `read_to_end`)
// is what makes printing partway through the download possible, without
// pulling in a progress-bar crate for what's meant to just be reassurance.
let content_length: Option<u64> = resp.header("Content-Length").and_then(|v| v.parse().ok());
let show_progress = content_length.is_some() && std::io::stderr().is_terminal();
let mut buf = Vec::new();
resp.into_reader()
.take(MAX_RESPONSE_BYTES + 1)
.read_to_end(&mut buf)
.context("reading response")?;
if buf.len() as u64 > MAX_RESPONSE_BYTES {
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
let mut reader = resp.into_reader();
let mut chunk = [0u8; CHUNK_SIZE];
let mut last_print = std::time::Instant::now();
loop {
let n = reader.read(&mut chunk).context("reading response")?;
if n == 0 {
break;
}
buf.extend_from_slice(&chunk[..n]);
if buf.len() as u64 > MAX_RESPONSE_BYTES {
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
}
if show_progress && last_print.elapsed() >= PROGRESS_THROTTLE {
print_progress(buf.len() as u64, content_length.unwrap());
last_print = std::time::Instant::now();
}
}
if show_progress {
print_progress(buf.len() as u64, content_length.unwrap());
eprintln!();
}
Ok(buf)
}
fn print_progress(downloaded: u64, total: u64) {
use std::io::Write;
eprint!(
"\r ⇣ {:.1}/{:.1} MB",
downloaded as f64 / 1_048_576.0,
total as f64 / 1_048_576.0
);
let _ = std::io::stderr().flush();
}
#[cfg(test)]
mod tests {
use super::*;