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:
parent
d45fc422f2
commit
eba8cb6c44
9 changed files with 785 additions and 69 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -110,6 +110,7 @@ dependencies = [
|
||||||
"bread-utils",
|
"bread-utils",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"clap_complete",
|
||||||
"dirs",
|
"dirs",
|
||||||
"fs4",
|
"fs4",
|
||||||
"hex",
|
"hex",
|
||||||
|
|
@ -330,6 +331,15 @@ dependencies = [
|
||||||
"strsim",
|
"strsim",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_complete"
|
||||||
|
version = "4.6.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b1f84a88507dbd05c695f2cb5e8558e747179134005e9893882dec964190ed89"
|
||||||
|
dependencies = [
|
||||||
|
"clap",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap_derive"
|
name = "clap_derive"
|
||||||
version = "4.6.1"
|
version = "4.6.1"
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ ureq = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
hex = { workspace = true }
|
hex = { workspace = true }
|
||||||
clap = { workspace = true }
|
clap = { workspace = true }
|
||||||
|
clap_complete = "4"
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
minisign-verify = { workspace = true }
|
minisign-verify = { workspace = true }
|
||||||
semver = { workspace = true }
|
semver = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,11 @@ use crate::manifest::{fetch_binary, Binary};
|
||||||
/// Download a binary, verify its SHA-256, then atomically write it into
|
/// Download a binary, verify its SHA-256, then atomically write it into
|
||||||
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
|
/// place (fsynced, temp-in-same-dir-with-unique-name then rename — see
|
||||||
/// `bread_utils::atomic`). Bails before touching `dest` if the checksum
|
/// `bread_utils::atomic`). Bails before touching `dest` if the checksum
|
||||||
/// fails.
|
/// fails. Returns the verified hex sha256 so callers (`install::
|
||||||
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
|
/// install_package`) can record it for `bakery verify` without hashing the
|
||||||
|
/// bytes a second time — `verify_sha256` already confirmed `bytes` matches
|
||||||
|
/// `binary.sha256`, so that's the value to return.
|
||||||
|
pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
|
||||||
println!(" downloading {}…", binary.name);
|
println!(" downloading {}…", binary.name);
|
||||||
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
let bytes = fetch_binary(&binary.dl_url, &binary.github_url)
|
||||||
.with_context(|| format!("downloading {}", binary.name))?;
|
.with_context(|| format!("downloading {}", binary.name))?;
|
||||||
|
|
@ -19,7 +22,7 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
|
||||||
bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
|
bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
|
||||||
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
||||||
println!(" installed {}", dest.display());
|
println!(" installed {}", dest.display());
|
||||||
Ok(())
|
Ok(binary.sha256.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that `bytes` hashes to `expected_hex` under SHA-256.
|
/// Verify that `bytes` hashes to `expected_hex` under SHA-256.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::io::IsTerminal;
|
use std::io::IsTerminal;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
@ -41,24 +42,39 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
|
||||||
matches!(buf.trim().to_lowercase().as_str(), "y" | "yes")
|
matches!(buf.trim().to_lowercase().as_str(), "y" | "yes")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Installs `pkg`. `previous` is the package's current `InstalledPackage`
|
||||||
|
/// record when this is an update (looked up by the caller before starting,
|
||||||
|
/// since it's already loaded elsewhere in the call chain) — `None` for a
|
||||||
|
/// fresh first-time install. Threading it in rather than reloading `State`
|
||||||
|
/// here avoids a second load, and lets step 1 tell "update" from "fresh
|
||||||
|
/// install" for the pre-overwrite backup below.
|
||||||
pub fn install_package(
|
pub fn install_package(
|
||||||
pkg: &Package,
|
pkg: &Package,
|
||||||
bin_dir: &Path,
|
bin_dir: &Path,
|
||||||
track: Track,
|
track: Track,
|
||||||
|
previous: Option<&InstalledPackage>,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
ensure_safe_component(&pkg.name, "package name")?;
|
ensure_safe_component(&pkg.name, "package name")?;
|
||||||
println!("installing {}@{}…", pkg.name, pkg.version);
|
println!("installing {}@{}…", pkg.name, pkg.version);
|
||||||
|
|
||||||
// 1. Download and verify all binaries.
|
// 1. Download and verify all binaries. On an update (not a fresh
|
||||||
|
// install), back up the current binary first — best-effort, feeding
|
||||||
|
// `bakery rollback` — before it's overwritten below.
|
||||||
|
let backup_dir = previous.map(|prev| crate::state::backup_dir(&pkg.name, &prev.version));
|
||||||
let mut binary_names = Vec::new();
|
let mut binary_names = Vec::new();
|
||||||
|
let mut binary_sha256 = HashMap::new();
|
||||||
for bin in &pkg.binaries {
|
for bin in &pkg.binaries {
|
||||||
ensure_safe_component(&bin.name, "binary name")?;
|
ensure_safe_component(&bin.name, "binary name")?;
|
||||||
let install_name = strip_arch_suffix(&bin.name);
|
let install_name = strip_arch_suffix(&bin.name);
|
||||||
let dest = bin_dir.join(install_name);
|
let dest = bin_dir.join(install_name);
|
||||||
fetch_and_place(bin, &dest)?;
|
if let Some(dir) = &backup_dir {
|
||||||
|
backup_current_binary(dir, install_name, &dest);
|
||||||
|
}
|
||||||
|
let sha256 = fetch_and_place(bin, &dest)?;
|
||||||
binary_names.push(install_name.to_string());
|
binary_names.push(install_name.to_string());
|
||||||
|
binary_sha256.insert(install_name.to_string(), sha256);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Scaffold config dir + download example file.
|
// 2. Scaffold config dir + download example file.
|
||||||
|
|
@ -124,6 +140,8 @@ pub fn install_package(
|
||||||
services: service_names,
|
services: service_names,
|
||||||
installed_at: chrono::Utc::now().to_rfc3339(),
|
installed_at: chrono::Utc::now().to_rfc3339(),
|
||||||
track,
|
track,
|
||||||
|
previous_version: previous.map(|p| p.version.clone()),
|
||||||
|
binary_sha256,
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
@ -133,7 +151,33 @@ pub fn install_package(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool) -> Result<()> {
|
/// Best-effort copy of the on-disk binary into `backup_dir` before it's
|
||||||
|
/// overwritten by an update — feeds `bakery rollback`. `backup_dir` is
|
||||||
|
/// deliberately a local path (ultimately under `~/.local/state/bakery/
|
||||||
|
/// backups/<pkg>/<old-version>/`, see `state::backup_dir`) rather than
|
||||||
|
/// `bakery rollback` re-fetching the old version from `dl.breadway.dev`:
|
||||||
|
/// `index.json`'s minisign signature only covers the *current* published
|
||||||
|
/// version's checksums, so verifying an old version pulled fresh from the
|
||||||
|
/// server would only be checkable against its unsigned per-version
|
||||||
|
/// `.sha256` sidecar — a materially weaker guarantee than bakery's normal
|
||||||
|
/// trust model. A local pre-update snapshot sidesteps that gap entirely.
|
||||||
|
fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Path) {
|
||||||
|
if !current_path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::create_dir_all(backup_dir) {
|
||||||
|
eprintln!(
|
||||||
|
" warning: could not create backup dir {} ({e}) — rollback won't be available for this update",
|
||||||
|
backup_dir.display()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Err(e) = std::fs::copy(current_path, backup_dir.join(binary_name)) {
|
||||||
|
eprintln!(" warning: could not back up {binary_name} before update ({e}) — rollback won't be available for this update");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: bool) -> Result<()> {
|
||||||
let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
|
let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
|
||||||
let installed = match installed {
|
let installed = match installed {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
|
|
@ -179,31 +223,69 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool) -> Resul
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Never touch config or data dirs.
|
// Config is never touched, even with --purge: unlike the license/desktop
|
||||||
|
// /data paths below (all bakery-downloaded or -extracted content,
|
||||||
|
// reproducible from source), the config dir holds user-authored/edited
|
||||||
|
// content bakery never wrote — silently destroying it would be a bad
|
||||||
|
// surprise no flag should cause.
|
||||||
if let Some(cfg_dir) = guess_config_dir(pkg_name) {
|
if let Some(cfg_dir) = guess_config_dir(pkg_name) {
|
||||||
if cfg_dir.exists() {
|
if cfg_dir.exists() {
|
||||||
println!(" config preserved at {}", cfg_dir.display());
|
println!(" config preserved at {}", cfg_dir.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let data_dir = dirs::data_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
let share_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"));
|
||||||
.join(pkg_name);
|
let data_dir = share_dir.join(pkg_name);
|
||||||
if data_dir.exists() {
|
|
||||||
|
if purge {
|
||||||
|
let license_dir = share_dir.join("licenses").join(pkg_name);
|
||||||
|
remove_purged_path(&license_dir, "license dir", true, assume_yes, &mut failures);
|
||||||
|
|
||||||
|
let desktop_file = share_dir.join("applications").join(format!("{pkg_name}.desktop"));
|
||||||
|
remove_purged_path(&desktop_file, "desktop entry", false, assume_yes, &mut failures);
|
||||||
|
|
||||||
|
remove_purged_path(&data_dir, "data dir", true, assume_yes, &mut failures);
|
||||||
|
} else if data_dir.exists() {
|
||||||
println!(" data preserved at {}", data_dir.display());
|
println!(" data preserved at {}", data_dir.display());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !failures.is_empty() {
|
if !failures.is_empty() {
|
||||||
eprintln!(" failed to remove {} binary/binaries:", failures.len());
|
eprintln!(" failed to remove {} item(s):", failures.len());
|
||||||
for f in &failures {
|
for f in &failures {
|
||||||
eprintln!(" {f}");
|
eprintln!(" {f}");
|
||||||
}
|
}
|
||||||
bail!("{pkg_name} removed from state, but some binaries could not be deleted");
|
bail!("{pkg_name} removed from state, but some files could not be deleted");
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(" {pkg_name} removed");
|
println!(" {pkg_name} removed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Confirms (via `confirm`, so `--yes` and non-tty stdin behave the same as
|
||||||
|
/// every other destructive prompt in this file) and removes `path` — a
|
||||||
|
/// directory when `recursive`, otherwise a single file. Declining leaves it
|
||||||
|
/// in place and prints the same "preserved at" wording the non-purge path
|
||||||
|
/// already uses. Shared by `remove_package`'s three `--purge` targets
|
||||||
|
/// (license dir, desktop entry, data dir).
|
||||||
|
fn remove_purged_path(path: &Path, label: &str, recursive: bool, assume_yes: bool, failures: &mut Vec<String>) {
|
||||||
|
if !path.exists() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !confirm(&format!(" remove {label} at {}?", path.display()), assume_yes) {
|
||||||
|
println!(" {label} preserved at {}", path.display());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let result = if recursive {
|
||||||
|
std::fs::remove_dir_all(path)
|
||||||
|
} else {
|
||||||
|
std::fs::remove_file(path)
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(()) => println!(" removed {}", path.display()),
|
||||||
|
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Result<()> {
|
fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Result<()> {
|
||||||
let dir = expand_tilde(&cfg.dir);
|
let dir = expand_tilde(&cfg.dir);
|
||||||
std::fs::create_dir_all(&dir)?;
|
std::fs::create_dir_all(&dir)?;
|
||||||
|
|
@ -521,7 +603,7 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||||
.lines()
|
.lines()
|
||||||
.map(|line| {
|
.map(|line| {
|
||||||
if line.trim_start().starts_with("ExecStart=") {
|
if line.trim_start().starts_with("ExecStart=") {
|
||||||
let rest = line.splitn(2, '=').nth(1).unwrap_or("");
|
let rest = line.split_once('=').map(|(_, v)| v).unwrap_or("");
|
||||||
let argv: Vec<&str> = rest.split_whitespace().collect();
|
let argv: Vec<&str> = rest.split_whitespace().collect();
|
||||||
if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) {
|
if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) {
|
||||||
let new_path = bin_dir.join(bin_name);
|
let new_path = bin_dir.join(bin_name);
|
||||||
|
|
@ -874,7 +956,7 @@ mod tests {
|
||||||
let mut pkg = test_package(&base_url);
|
let mut pkg = test_package(&base_url);
|
||||||
pkg.name = "../evil".to_string();
|
pkg.name = "../evil".to_string();
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let err = install_package(&pkg, dir.path(), Track::Stable, true, true).unwrap_err();
|
let err = install_package(&pkg, dir.path(), Track::Stable, None, true, true).unwrap_err();
|
||||||
assert!(err.to_string().contains("not a safe filename"));
|
assert!(err.to_string().contains("not a safe filename"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -934,4 +1016,70 @@ mod tests {
|
||||||
assert!(out.contains("Description=foo"));
|
assert!(out.contains("Description=foo"));
|
||||||
assert!(out.contains("ExecStart=/usr/bin/foo"));
|
assert!(out.contains("ExecStart=/usr/bin/foo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_current_binary_copies_existing_file() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let current = dir.path().join("mypkg");
|
||||||
|
fs::write(¤t, b"old version bytes").unwrap();
|
||||||
|
let backup_dir = dir.path().join("backups/mypkg/1.0.0");
|
||||||
|
|
||||||
|
backup_current_binary(&backup_dir, "mypkg", ¤t);
|
||||||
|
|
||||||
|
assert_eq!(fs::read(backup_dir.join("mypkg")).unwrap(), b"old version bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_current_binary_skips_missing_source_without_erroring() {
|
||||||
|
// The "fresh install, nothing to back up yet" case — must not create
|
||||||
|
// an empty backup dir or panic.
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let current = dir.path().join("does-not-exist");
|
||||||
|
let backup_dir = dir.path().join("backups/mypkg/1.0.0");
|
||||||
|
|
||||||
|
backup_current_binary(&backup_dir, "mypkg", ¤t);
|
||||||
|
|
||||||
|
assert!(!backup_dir.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_purged_path_removes_directory_when_confirmed() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let target = dir.path().join("licenses/mypkg");
|
||||||
|
fs::create_dir_all(&target).unwrap();
|
||||||
|
fs::write(target.join("LICENSE"), b"MIT").unwrap();
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
|
||||||
|
remove_purged_path(&target, "license dir", true, true, &mut failures);
|
||||||
|
|
||||||
|
assert!(!target.exists());
|
||||||
|
assert!(failures.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_purged_path_preserves_when_declined() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let target = dir.path().join("mypkg.desktop");
|
||||||
|
fs::write(&target, b"[Desktop Entry]").unwrap();
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
|
||||||
|
// assume_yes=false and a non-tty stdin (the test harness) means
|
||||||
|
// `confirm` answers "no" — same as `confirm_returns_false_when_not_
|
||||||
|
// assume_yes_and_stdin_not_a_tty` above.
|
||||||
|
remove_purged_path(&target, "desktop entry", false, false, &mut failures);
|
||||||
|
|
||||||
|
assert!(target.exists());
|
||||||
|
assert!(failures.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_purged_path_missing_target_is_a_no_op() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let target = dir.path().join("nope");
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
|
||||||
|
remove_purged_path(&target, "data dir", true, true, &mut failures);
|
||||||
|
|
||||||
|
assert!(failures.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@ mod track;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{CommandFactory, Parser, Subcommand};
|
||||||
use std::collections::HashSet;
|
use sha2::{Digest, Sha256};
|
||||||
use std::path::PathBuf;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use track::Track;
|
use track::Track;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
|
|
@ -26,6 +27,9 @@ struct Cli {
|
||||||
/// Assume yes to interactive prompts
|
/// Assume yes to interactive prompts
|
||||||
#[arg(short = 'y', long = "yes", global = true)]
|
#[arg(short = 'y', long = "yes", global = true)]
|
||||||
yes: bool,
|
yes: bool,
|
||||||
|
/// Show what would happen without downloading, writing, or touching state
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
dry_run: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
|
|
@ -35,9 +39,13 @@ enum Cmd {
|
||||||
#[arg(required = true, num_args = 1..)]
|
#[arg(required = true, num_args = 1..)]
|
||||||
packages: Vec<String>,
|
packages: Vec<String>,
|
||||||
},
|
},
|
||||||
/// Remove an installed package (data files are never deleted)
|
/// Remove an installed package (config is never deleted)
|
||||||
Remove {
|
Remove {
|
||||||
package: String,
|
package: String,
|
||||||
|
/// Also remove the license file, desktop entry, and data dir
|
||||||
|
/// (~/.local/share/<pkg>/) — config is still preserved
|
||||||
|
#[arg(long)]
|
||||||
|
purge: bool,
|
||||||
},
|
},
|
||||||
/// Update one or all installed packages
|
/// Update one or all installed packages
|
||||||
Update {
|
Update {
|
||||||
|
|
@ -58,11 +66,31 @@ enum Cmd {
|
||||||
Info {
|
Info {
|
||||||
package: String,
|
package: String,
|
||||||
},
|
},
|
||||||
|
/// Search package names and descriptions
|
||||||
|
Search {
|
||||||
|
query: String,
|
||||||
|
},
|
||||||
/// Check system dependencies for installed or requested packages
|
/// Check system dependencies for installed or requested packages
|
||||||
Doctor {
|
Doctor {
|
||||||
/// Package to check; omit to check all installed packages
|
/// Package to check; omit to check all installed packages
|
||||||
package: Option<String>,
|
package: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// Verify installed binaries against the checksum recorded at install time
|
||||||
|
Verify {
|
||||||
|
/// Package to verify; omit to verify all installed packages
|
||||||
|
package: Option<String>,
|
||||||
|
},
|
||||||
|
/// Roll back a package to its previously installed version, from a
|
||||||
|
/// local pre-update backup (not a re-download)
|
||||||
|
Rollback {
|
||||||
|
package: String,
|
||||||
|
},
|
||||||
|
/// Update bakery itself
|
||||||
|
SelfUpdate,
|
||||||
|
/// Generate a shell completion script
|
||||||
|
Completions {
|
||||||
|
shell: clap_complete::Shell,
|
||||||
|
},
|
||||||
/// View or switch which build track bakery follows (stable/beta/dev)
|
/// View or switch which build track bakery follows (stable/beta/dev)
|
||||||
Track {
|
Track {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|
@ -90,27 +118,43 @@ fn main() -> Result<()> {
|
||||||
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
|
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
|
||||||
let no_hooks = cli.no_hooks;
|
let no_hooks = cli.no_hooks;
|
||||||
let assume_yes = cli.yes;
|
let assume_yes = cli.yes;
|
||||||
|
let dry_run = cli.dry_run;
|
||||||
let track = state::State::load()?.track;
|
let track = state::State::load()?.track;
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Cmd::Install { packages } => {
|
Cmd::Install { packages } => {
|
||||||
let index = manifest::load(true, track)?;
|
let index = manifest::load(true, track)?;
|
||||||
for pkg in &packages {
|
for pkg in &packages {
|
||||||
cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes)?;
|
cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes, dry_run)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Cmd::Remove { package } => cmd_remove(&package, &bin_dir, assume_yes),
|
Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge),
|
||||||
Cmd::Update { package, all } => {
|
Cmd::Update { package, all } => {
|
||||||
cmd_update(package.as_deref(), all, &bin_dir, track, no_hooks, assume_yes)
|
cmd_update(package.as_deref(), all, &bin_dir, track, no_hooks, assume_yes, dry_run)
|
||||||
}
|
}
|
||||||
Cmd::List { installed } => cmd_list(installed, track),
|
Cmd::List { installed } => cmd_list(installed, track),
|
||||||
Cmd::Info { package } => cmd_info(&package, track),
|
Cmd::Info { package } => cmd_info(&package, track),
|
||||||
|
Cmd::Search { query } => cmd_search(&query, track),
|
||||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir),
|
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir),
|
||||||
|
Cmd::Verify { package } => cmd_verify(package.as_deref(), &bin_dir),
|
||||||
|
Cmd::Rollback { package } => cmd_rollback(&package, &bin_dir),
|
||||||
|
// Same update logic as `bakery update bakery` — this is just a
|
||||||
|
// documented, discoverable entry point for it, since overwriting
|
||||||
|
// bakery's own running binary via a normal update already works
|
||||||
|
// (rename-over-running-binary is safe on Linux) but wasn't a real
|
||||||
|
// first-class command.
|
||||||
|
Cmd::SelfUpdate => cmd_update(Some("bakery"), false, &bin_dir, track, no_hooks, assume_yes, dry_run),
|
||||||
|
Cmd::Completions { shell } => cmd_completions(shell),
|
||||||
Cmd::Track { action } => cmd_track(action),
|
Cmd::Track { action } => cmd_track(action),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_completions(shell: clap_complete::Shell) -> Result<()> {
|
||||||
|
clap_complete::generate(shell, &mut Cli::command(), "bakery", &mut std::io::stdout());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_track(action: TrackCmd) -> Result<()> {
|
fn cmd_track(action: TrackCmd) -> Result<()> {
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
match action {
|
match action {
|
||||||
|
|
@ -148,9 +192,10 @@ fn cmd_install(
|
||||||
track: Track,
|
track: Track,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
|
dry_run: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, &mut visited)
|
install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, dry_run, &mut visited)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively installs `name` and any bread_deps, skipping already-installed
|
/// Recursively installs `name` and any bread_deps, skipping already-installed
|
||||||
|
|
@ -163,6 +208,7 @@ fn install_with_deps(
|
||||||
track: Track,
|
track: Track,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
|
dry_run: bool,
|
||||||
visited: &mut HashSet<String>,
|
visited: &mut HashSet<String>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !visited.insert(name.to_string()) {
|
if !visited.insert(name.to_string()) {
|
||||||
|
|
@ -177,16 +223,18 @@ fn install_with_deps(
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
for dep in pkg.bread_deps.clone() {
|
for dep in pkg.bread_deps.clone() {
|
||||||
if !state.is_installed(&dep) {
|
if !state.is_installed(&dep) {
|
||||||
println!("installing bread dependency: {dep}");
|
println!("{} bread dependency: {dep}", if dry_run { "would install" } else { "installing" });
|
||||||
install_with_deps(index, &dep, bin_dir, track, no_hooks, assume_yes, visited)?;
|
install_with_deps(index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let previous = state.packages.get(name);
|
||||||
|
|
||||||
// Already installed and not older than the index — nothing to do. This
|
// Already installed and not older than the index — nothing to do. This
|
||||||
// doubles as the implicit upgrade path when the index has something
|
// doubles as the implicit upgrade path when the index has something
|
||||||
// newer, so `bakery install <pkg>` is safe to run repeatedly instead of
|
// newer, so `bakery install <pkg>` is safe to run repeatedly instead of
|
||||||
// silently reinstalling (and potentially downgrading) every time.
|
// silently reinstalling (and potentially downgrading) every time.
|
||||||
if let Some(installed) = state.packages.get(name) {
|
if let Some(installed) = previous {
|
||||||
if !is_newer(&installed.version, &pkg.version) {
|
if !is_newer(&installed.version, &pkg.version) {
|
||||||
println!(
|
println!(
|
||||||
"{name} already installed at {} (index has {})",
|
"{name} already installed at {} (index has {})",
|
||||||
|
|
@ -207,11 +255,41 @@ fn install_with_deps(
|
||||||
bail!("system deps not satisfied");
|
bail!("system deps not satisfied");
|
||||||
}
|
}
|
||||||
|
|
||||||
install::install_package(pkg, bin_dir, track, no_hooks, assume_yes)
|
if dry_run {
|
||||||
|
print_dry_run_plan(pkg, previous);
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool) -> Result<()> {
|
install::install_package(pkg, bin_dir, track, previous, no_hooks, assume_yes)
|
||||||
install::remove_package(name, bin_dir, assume_yes)
|
}
|
||||||
|
|
||||||
|
/// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under
|
||||||
|
/// `--dry-run`, once the version-comparison decision to actually act has
|
||||||
|
/// already been made — this only renders that decision, it never
|
||||||
|
/// recomputes it, so dry-run and real runs can't drift apart on "would this
|
||||||
|
/// update happen at all".
|
||||||
|
fn print_dry_run_plan(pkg: &manifest::Package, previous: Option<&state::InstalledPackage>) {
|
||||||
|
let verb = if previous.is_some() { "update" } else { "install" };
|
||||||
|
println!(
|
||||||
|
" {} would {verb} {} to {}",
|
||||||
|
ui::style("dry-run:", ui::DIM),
|
||||||
|
pkg.name,
|
||||||
|
ui::style(&pkg.version, ui::BOLD)
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" binaries: {}",
|
||||||
|
pkg.binaries.iter().map(|b| b.name.as_str()).collect::<Vec<_>>().join(", ")
|
||||||
|
);
|
||||||
|
if !pkg.services.is_empty() {
|
||||||
|
println!(
|
||||||
|
" services: {}",
|
||||||
|
pkg.services.iter().map(|s| s.unit.as_str()).collect::<Vec<_>>().join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool, purge: bool) -> Result<()> {
|
||||||
|
install::remove_package(name, bin_dir, assume_yes, purge)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|
@ -222,14 +300,14 @@ fn cmd_update(
|
||||||
track: Track,
|
track: Track,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
|
dry_run: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let index = manifest::load(true, track)?;
|
let index = manifest::load(true, track)?;
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
|
|
||||||
let targets: Vec<String> = if all || name.is_none() {
|
let targets: Vec<String> = match name {
|
||||||
state.packages.keys().cloned().collect()
|
Some(n) if !all => vec![n.to_string()],
|
||||||
} else {
|
_ => state.packages.keys().cloned().collect(),
|
||||||
vec![name.unwrap().to_string()]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
|
|
@ -238,6 +316,8 @@ fn cmd_update(
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut any_failed = false;
|
let mut any_failed = false;
|
||||||
|
let mut updated = 0u32;
|
||||||
|
let mut unchanged = 0u32;
|
||||||
for pkg_name in &targets {
|
for pkg_name in &targets {
|
||||||
let installed = match state.packages.get(pkg_name.as_str()) {
|
let installed = match state.packages.get(pkg_name.as_str()) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
|
|
@ -263,7 +343,16 @@ fn cmd_update(
|
||||||
// beta RC that shares a base version with the installed stable).
|
// beta RC that shares a base version with the installed stable).
|
||||||
let track_switch = installed.track != track;
|
let track_switch = installed.track != track;
|
||||||
if !should_update(&installed.version, installed.track, track, &latest.version) {
|
if !should_update(&installed.version, installed.track, track, &latest.version) {
|
||||||
println!("{}", ui::style(&format!("{pkg_name} is already at {}", installed.version), ui::GREEN));
|
// DIM, not GREEN — this is the steady-state common case (most
|
||||||
|
// packages, most runs), and reusing GREEN here drowns out the
|
||||||
|
// packages that actually changed below. The glyph itself
|
||||||
|
// (rather than just color) keeps that distinction legible even
|
||||||
|
// under a terminal palette that maps ANSI colors unusually.
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
ui::unchanged(&format!("{pkg_name} is already at {}", installed.version))
|
||||||
|
);
|
||||||
|
unchanged += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,12 +393,31 @@ fn cmd_update(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = install::install_package(latest, bin_dir, track, no_hooks, assume_yes) {
|
if dry_run {
|
||||||
|
print_dry_run_plan(latest, Some(installed));
|
||||||
|
updated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = install::install_package(latest, bin_dir, track, Some(installed), no_hooks, assume_yes) {
|
||||||
eprintln!(" failed to update {pkg_name}: {e}");
|
eprintln!(" failed to update {pkg_name}: {e}");
|
||||||
any_failed = true;
|
any_failed = true;
|
||||||
|
} else {
|
||||||
|
updated += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only for --all: a single named update already makes its own outcome
|
||||||
|
// obvious, and "1 updated, 0 already up to date" isn't a useful takeaway.
|
||||||
|
if all {
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if updated > 0 {
|
||||||
|
parts.push(format!("{updated} updated"));
|
||||||
|
}
|
||||||
|
parts.push(format!("{unchanged} already up to date"));
|
||||||
|
println!("{}", ui::style(&parts.join(", "), ui::BOLD));
|
||||||
|
}
|
||||||
|
|
||||||
if any_failed {
|
if any_failed {
|
||||||
bail!("one or more packages could not be updated");
|
bail!("one or more packages could not be updated");
|
||||||
}
|
}
|
||||||
|
|
@ -347,6 +455,17 @@ fn is_newer(installed: &str, latest: &str) -> bool {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prints one index entry in the shared `list`/`search` format: name,
|
||||||
|
/// version, description, and an `[installed <version>]` tag when applicable.
|
||||||
|
fn print_index_entry(pkg: &manifest::Package, state: &state::State) {
|
||||||
|
let tag = if state.is_installed(&pkg.name) {
|
||||||
|
ui::style(&format!(" [installed {}]", state.packages[&pkg.name].version), ui::GREEN)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag);
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
||||||
let state = state::State::load()?;
|
let state = state::State::load()?;
|
||||||
|
|
||||||
|
|
@ -368,13 +487,38 @@ fn cmd_list(installed_only: bool, track: Track) -> Result<()> {
|
||||||
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
let mut names: Vec<&str> = index.packages.keys().map(|s| s.as_str()).collect();
|
||||||
names.sort();
|
names.sort();
|
||||||
for name in names {
|
for name in names {
|
||||||
let pkg = &index.packages[name];
|
print_index_entry(&index.packages[name], &state);
|
||||||
let tag = if state.is_installed(name) {
|
}
|
||||||
ui::style(&format!(" [installed {}]", state.packages[name].version), ui::GREEN)
|
Ok(())
|
||||||
} else {
|
}
|
||||||
String::new()
|
|
||||||
};
|
/// Case-insensitive substring match against a package's name or description
|
||||||
println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag);
|
/// — split out from `cmd_search` so the matching rule itself is testable
|
||||||
|
/// without a real index load.
|
||||||
|
fn matches_search(name: &str, description: &str, needle_lower: &str) -> bool {
|
||||||
|
name.to_lowercase().contains(needle_lower) || description.to_lowercase().contains(needle_lower)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_search(query: &str, track: Track) -> Result<()> {
|
||||||
|
let state = state::State::load()?;
|
||||||
|
let index = manifest::load(false, track)?;
|
||||||
|
let needle = query.to_lowercase();
|
||||||
|
|
||||||
|
let mut names: Vec<&str> = index
|
||||||
|
.packages
|
||||||
|
.values()
|
||||||
|
.filter(|pkg| matches_search(&pkg.name, &pkg.description, &needle))
|
||||||
|
.map(|pkg| pkg.name.as_str())
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
|
||||||
|
if names.is_empty() {
|
||||||
|
println!("no packages matched '{query}'");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
for name in names {
|
||||||
|
print_index_entry(&index.packages[name], &state);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -458,8 +602,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
|
||||||
// System-deps checks alone can't catch a partially-broken install
|
// System-deps checks alone can't catch a partially-broken install
|
||||||
// (e.g. a binary manually deleted after install) — also confirm
|
// (e.g. a binary manually deleted after install) — also confirm
|
||||||
// every binary this package recorded is still on disk. Existence
|
// every binary this package recorded is still on disk. Existence
|
||||||
// only, not a checksum re-verification — that's the scope of a
|
// only, not a checksum re-verification — see `bakery verify` for that.
|
||||||
// future `bakery verify` command.
|
|
||||||
if let Some(installed) = state.packages.get(pkg_name) {
|
if let Some(installed) = state.packages.get(pkg_name) {
|
||||||
for bin in &installed.binaries {
|
for bin in &installed.binaries {
|
||||||
let path = bin_dir.join(bin);
|
let path = bin_dir.join(bin);
|
||||||
|
|
@ -483,9 +626,184 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum VerifyStatus {
|
||||||
|
Ok,
|
||||||
|
Missing,
|
||||||
|
Tampered,
|
||||||
|
/// `binary_sha256` has no entry for this binary — an install that
|
||||||
|
/// predates `bakery verify` support. Reported plainly rather than
|
||||||
|
/// folded into `Ok`, since there's nothing to actually compare against.
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recomputes `binary_name`'s on-disk sha256 in `bin_dir` and compares it
|
||||||
|
/// against `expected` — the hash recorded in `InstalledPackage::
|
||||||
|
/// binary_sha256` at install time, not a fresh index lookup. The index
|
||||||
|
/// only carries the checksum for whatever the *current latest* release is,
|
||||||
|
/// which may not be what's actually installed if the user hasn't updated
|
||||||
|
/// yet; comparing against that would produce a false "tampered" report for
|
||||||
|
/// a perfectly intact, just-not-latest binary.
|
||||||
|
fn verify_binary(bin_dir: &Path, binary_name: &str, expected: Option<&String>) -> VerifyStatus {
|
||||||
|
let Some(expected) = expected else {
|
||||||
|
return VerifyStatus::Unknown;
|
||||||
|
};
|
||||||
|
let path = bin_dir.join(binary_name);
|
||||||
|
let Ok(bytes) = std::fs::read(&path) else {
|
||||||
|
return VerifyStatus::Missing;
|
||||||
|
};
|
||||||
|
let actual = hex::encode(Sha256::digest(&bytes));
|
||||||
|
if &actual == expected {
|
||||||
|
VerifyStatus::Ok
|
||||||
|
} else {
|
||||||
|
VerifyStatus::Tampered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_verify(name: Option<&str>, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
|
let state = state::State::load()?;
|
||||||
|
|
||||||
|
let targets: Vec<String> = match name {
|
||||||
|
Some(n) => {
|
||||||
|
if !state.is_installed(n) {
|
||||||
|
bail!("{n} is not installed");
|
||||||
|
}
|
||||||
|
vec![n.to_string()]
|
||||||
|
}
|
||||||
|
None => state.packages.keys().cloned().collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if targets.is_empty() {
|
||||||
|
println!("no packages installed — nothing to verify");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut any_bad = false;
|
||||||
|
for pkg_name in &targets {
|
||||||
|
let installed = &state.packages[pkg_name];
|
||||||
|
if installed.binary_sha256.is_empty() {
|
||||||
|
println!(
|
||||||
|
" {} {pkg_name}: no recorded checksums (installed before 'bakery verify' support)",
|
||||||
|
ui::style("?", ui::DIM)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for bin in &installed.binaries {
|
||||||
|
match verify_binary(bin_dir, bin, installed.binary_sha256.get(bin)) {
|
||||||
|
VerifyStatus::Ok => println!(" {}", ui::ok(&format!("{pkg_name}: {bin}"))),
|
||||||
|
VerifyStatus::Missing => {
|
||||||
|
eprintln!(" {}", ui::fail(&format!("{pkg_name}: {bin} — MISSING")));
|
||||||
|
any_bad = true;
|
||||||
|
}
|
||||||
|
VerifyStatus::Tampered => {
|
||||||
|
eprintln!(
|
||||||
|
" {}",
|
||||||
|
ui::fail(&format!("{pkg_name}: {bin} — TAMPERED (checksum mismatch)"))
|
||||||
|
);
|
||||||
|
any_bad = true;
|
||||||
|
}
|
||||||
|
VerifyStatus::Unknown => {
|
||||||
|
println!(
|
||||||
|
" {} {pkg_name}: {bin} — UNKNOWN (no recorded checksum for this binary)",
|
||||||
|
ui::style("?", ui::DIM)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if any_bad {
|
||||||
|
bail!("verification failed for one or more binaries");
|
||||||
|
}
|
||||||
|
println!("{}", ui::ok("all recorded checksums match"));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies each of `binaries` from `backup_dir` into `bin_dir` (atomic,
|
||||||
|
/// executable — same as a normal install), returning the sha256 of each
|
||||||
|
/// restored binary so the caller can update `InstalledPackage::
|
||||||
|
/// binary_sha256` to match what's now actually on disk. The hash is
|
||||||
|
/// computed from the trusted local backup bytes directly, not re-verified
|
||||||
|
/// against any network source — see `install::backup_current_binary` for
|
||||||
|
/// why rollback is backup-based rather than a network re-pin in the first
|
||||||
|
/// place. Pure with respect to global state (caller supplies both dirs), so
|
||||||
|
/// this is the piece of `bakery rollback` that's directly unit-testable.
|
||||||
|
fn restore_binaries(backup_dir: &Path, binaries: &[String], bin_dir: &Path) -> Result<HashMap<String, String>> {
|
||||||
|
let mut sha256 = HashMap::new();
|
||||||
|
for bin in binaries {
|
||||||
|
let backup_path = backup_dir.join(bin);
|
||||||
|
if !backup_path.exists() {
|
||||||
|
bail!("backup for binary '{bin}' is missing at {}", backup_path.display());
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(&backup_path)
|
||||||
|
.with_context(|| format!("reading backup {}", backup_path.display()))?;
|
||||||
|
let hash = hex::encode(Sha256::digest(&bytes));
|
||||||
|
let dest = bin_dir.join(bin);
|
||||||
|
bread_utils::atomic::write_atomic_bytes(&dest, &bytes, Some(0o755))
|
||||||
|
.with_context(|| format!("restoring {}", dest.display()))?;
|
||||||
|
sha256.insert(bin.clone(), hash);
|
||||||
|
}
|
||||||
|
Ok(sha256)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolls `pkg_name` back to its previously installed version, restoring
|
||||||
|
/// binaries from the local pre-update backup `install::install_package`
|
||||||
|
/// made — not by re-fetching the old version from `dl.breadway.dev`.
|
||||||
|
/// Deliberately backup-based: `index.json`'s minisign signature only covers
|
||||||
|
/// the *current* published version's checksums, so verifying an old version
|
||||||
|
/// pulled fresh from the server would only be checkable against its
|
||||||
|
/// unsigned per-version `.sha256` sidecar file, a materially weaker
|
||||||
|
/// guarantee than bakery's normal trust model. The local backup sidesteps
|
||||||
|
/// that gap — it's bytes bakery itself copied from a binary that was
|
||||||
|
/// already verified against a signed index at the time it was installed.
|
||||||
|
fn cmd_rollback(pkg_name: &str, bin_dir: &std::path::Path) -> Result<()> {
|
||||||
|
let installed = state::State::load()?
|
||||||
|
.packages
|
||||||
|
.get(pkg_name)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("{pkg_name} is not installed"))?;
|
||||||
|
|
||||||
|
let target_version = installed.previous_version.clone().ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("no previous version recorded for {pkg_name} — nothing to roll back to")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let backup_dir = state::backup_dir(pkg_name, &target_version);
|
||||||
|
if !backup_dir.exists() {
|
||||||
|
bail!(
|
||||||
|
"no local backup found for {pkg_name} {target_version} at {} — cannot roll back",
|
||||||
|
backup_dir.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let binary_sha256 = restore_binaries(&backup_dir, &installed.binaries, bin_dir)?;
|
||||||
|
|
||||||
|
let from_version = installed.version.clone();
|
||||||
|
state::State::with_lock(|state| {
|
||||||
|
if let Some(pkg) = state.packages.get_mut(pkg_name) {
|
||||||
|
pkg.version = target_version.clone();
|
||||||
|
pkg.previous_version = Some(from_version.clone());
|
||||||
|
pkg.binary_sha256 = binary_sha256.clone();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Best-effort — a leftover backup dir after a successful rollback just
|
||||||
|
// wastes disk, it's not a correctness problem, so a failure here
|
||||||
|
// shouldn't fail the rollback itself.
|
||||||
|
let _ = std::fs::remove_dir_all(&backup_dir);
|
||||||
|
|
||||||
|
println!(
|
||||||
|
" {}",
|
||||||
|
ui::ok(&format!("rolled back {pkg_name} {from_version} → {target_version}"))
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn is_newer_detects_real_semver_increase() {
|
fn is_newer_detects_real_semver_increase() {
|
||||||
|
|
@ -533,4 +851,131 @@ mod tests {
|
||||||
fn should_update_true_when_same_track_and_newer() {
|
fn should_update_true_when_same_track_and_newer() {
|
||||||
assert!(should_update("0.3.1", Track::Stable, Track::Stable, "0.3.2"));
|
assert!(should_update("0.3.1", Track::Stable, Track::Stable, "0.3.2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_search_matches_name_case_insensitively() {
|
||||||
|
assert!(matches_search("BreadHelp", "onboarding guide", "breadhelp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_search_matches_description_substring() {
|
||||||
|
assert!(matches_search("breadhelp", "Onboarding Guide", "onboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_search_no_match_returns_false() {
|
||||||
|
assert!(!matches_search("breadhelp", "onboarding guide", "zzz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_binary_package(name: &str, version: &str, url: &str) -> manifest::Package {
|
||||||
|
manifest::Package {
|
||||||
|
name: name.to_string(),
|
||||||
|
description: "test".to_string(),
|
||||||
|
version: version.to_string(),
|
||||||
|
binaries: vec![manifest::Binary {
|
||||||
|
name: name.to_string(),
|
||||||
|
dl_url: url.to_string(),
|
||||||
|
github_url: url.to_string(),
|
||||||
|
sha256: "0".repeat(64),
|
||||||
|
}],
|
||||||
|
system_deps: vec![],
|
||||||
|
optional_system_deps: vec![],
|
||||||
|
bread_deps: vec![],
|
||||||
|
services: vec![],
|
||||||
|
config: None,
|
||||||
|
post_install: vec![],
|
||||||
|
license_file: None,
|
||||||
|
license_file_sha256: None,
|
||||||
|
desktop_file: None,
|
||||||
|
desktop_file_sha256: None,
|
||||||
|
data_archive: None,
|
||||||
|
data_archive_sha256: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_with_deps_dry_run_does_not_download_or_write() {
|
||||||
|
let name = "faketestpkg-dry-run";
|
||||||
|
// A reserved, essentially-guaranteed-unreachable port: if dry-run
|
||||||
|
// ever regressed into actually calling fetch_and_place, this would
|
||||||
|
// fail loudly (connection refused) instead of silently passing.
|
||||||
|
let pkg = empty_binary_package(name, "9.9.9", "http://127.0.0.1:1/unreachable");
|
||||||
|
let mut packages = std::collections::HashMap::new();
|
||||||
|
packages.insert(name.to_string(), pkg);
|
||||||
|
let index = manifest::Index { version: "1".to_string(), packages };
|
||||||
|
|
||||||
|
let bin_dir = tempdir().unwrap();
|
||||||
|
let mut visited = HashSet::new();
|
||||||
|
install_with_deps(
|
||||||
|
&index,
|
||||||
|
name,
|
||||||
|
bin_dir.path(),
|
||||||
|
Track::Stable,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true, // dry_run
|
||||||
|
&mut visited,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(fs::read_dir(bin_dir.path()).unwrap().next().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_binary_ok_when_hash_matches() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("mypkg"), b"good bytes").unwrap();
|
||||||
|
let hash = hex::encode(Sha256::digest(b"good bytes"));
|
||||||
|
assert_eq!(verify_binary(dir.path(), "mypkg", Some(&hash)), VerifyStatus::Ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_binary_tampered_when_hash_mismatches() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("mypkg"), b"tampered bytes").unwrap();
|
||||||
|
let wrong_hash = "0".repeat(64);
|
||||||
|
assert_eq!(verify_binary(dir.path(), "mypkg", Some(&wrong_hash)), VerifyStatus::Tampered);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_binary_missing_when_file_absent() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let hash = "0".repeat(64);
|
||||||
|
assert_eq!(verify_binary(dir.path(), "nope", Some(&hash)), VerifyStatus::Missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_binary_unknown_when_no_recorded_hash() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("mypkg"), b"bytes").unwrap();
|
||||||
|
assert_eq!(verify_binary(dir.path(), "mypkg", None), VerifyStatus::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_binaries_round_trips_backup_into_bin_dir() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let backup_dir = dir.path().join("backup");
|
||||||
|
fs::create_dir_all(&backup_dir).unwrap();
|
||||||
|
fs::write(backup_dir.join("mypkg"), b"old version bytes").unwrap();
|
||||||
|
let bin_dir = dir.path().join("bin");
|
||||||
|
fs::create_dir_all(&bin_dir).unwrap();
|
||||||
|
|
||||||
|
let hashes = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(fs::read(bin_dir.join("mypkg")).unwrap(), b"old version bytes");
|
||||||
|
assert_eq!(hashes["mypkg"], hex::encode(Sha256::digest(b"old version bytes")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_binaries_errors_clearly_when_backup_missing() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let backup_dir = dir.path().join("backup");
|
||||||
|
let bin_dir = dir.path().join("bin");
|
||||||
|
fs::create_dir_all(&bin_dir).unwrap();
|
||||||
|
|
||||||
|
let err = restore_binaries(&backup_dir, &["mypkg".to_string()], &bin_dir).unwrap_err();
|
||||||
|
|
||||||
|
assert!(err.to_string().contains("missing"));
|
||||||
|
assert!(fs::read_dir(&bin_dir).unwrap().next().is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -212,8 +212,8 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_and_verify_cache(
|
fn read_and_verify_cache(
|
||||||
cache_path: &PathBuf,
|
cache_path: &Path,
|
||||||
sig_cache_path: &PathBuf,
|
sig_cache_path: &Path,
|
||||||
track: Track,
|
track: Track,
|
||||||
) -> Result<Index> {
|
) -> Result<Index> {
|
||||||
let bytes = std::fs::read(cache_path).context("reading cached 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")
|
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)
|
std::fs::metadata(path)
|
||||||
.and_then(|m| m.modified())
|
.and_then(|m| m.modified())
|
||||||
.map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE)
|
.map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE)
|
||||||
.unwrap_or(false)
|
.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(|| {
|
let bytes = fetch_bytes(&primary_url(track)).with_context(|| {
|
||||||
format!(
|
format!(
|
||||||
"fetching {track} index — has a {track} build been published yet? \
|
"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.
|
/// gets buffered into memory before any trust check runs on it.
|
||||||
const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
|
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>> {
|
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
use std::io::Read;
|
use std::io::{IsTerminal, Read};
|
||||||
let resp = ureq::get(url)
|
let resp = ureq::get(url)
|
||||||
.call()
|
.call()
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
|
@ -306,17 +312,51 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
||||||
if status != 200 {
|
if status != 200 {
|
||||||
bail!("HTTP {status} from {url}");
|
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();
|
let mut buf = Vec::new();
|
||||||
resp.into_reader()
|
let mut reader = resp.into_reader();
|
||||||
.take(MAX_RESPONSE_BYTES + 1)
|
let mut chunk = [0u8; CHUNK_SIZE];
|
||||||
.read_to_end(&mut buf)
|
let mut last_print = std::time::Instant::now();
|
||||||
.context("reading response")?;
|
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 {
|
if buf.len() as u64 > MAX_RESPONSE_BYTES {
|
||||||
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
|
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)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,20 @@ pub struct InstalledPackage {
|
||||||
// convention as `State.track` above.
|
// convention as `State.track` above.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub track: Track,
|
pub track: Track,
|
||||||
|
/// The version this package was upgraded from, if any — `bakery
|
||||||
|
/// rollback` uses this to find the matching local backup dir. `None` on
|
||||||
|
/// a fresh first-time install. `#[serde(default)]` for the same
|
||||||
|
/// old-shape-json reason as `track` above.
|
||||||
|
#[serde(default)]
|
||||||
|
pub previous_version: Option<String>,
|
||||||
|
/// SHA-256 (hex) of each installed binary, captured at install time.
|
||||||
|
/// `bakery verify` recomputes these from disk and compares against this
|
||||||
|
/// recorded value rather than a fresh index lookup — the index only
|
||||||
|
/// carries the checksum for whatever the *current latest* release is,
|
||||||
|
/// which may not match what's actually installed. Empty on installs
|
||||||
|
/// that predate this field.
|
||||||
|
#[serde(default)]
|
||||||
|
pub binary_sha256: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||||
|
|
@ -91,14 +105,28 @@ impl State {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_path() -> PathBuf {
|
fn state_base_dir() -> PathBuf {
|
||||||
dirs::state_dir()
|
dirs::state_dir().unwrap_or_else(|| {
|
||||||
.unwrap_or_else(|| {
|
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("~"))
|
.unwrap_or_else(|| PathBuf::from("~"))
|
||||||
.join(".local/state")
|
.join(".local/state")
|
||||||
})
|
})
|
||||||
.join("bakery/installed.json")
|
}
|
||||||
|
|
||||||
|
fn state_path() -> PathBuf {
|
||||||
|
state_base_dir().join("bakery/installed.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Local backup dir for `pkg_name`'s `version` binaries, populated by
|
||||||
|
/// `install::install_package` right before an update overwrites the
|
||||||
|
/// previous binaries and consumed by `bakery rollback`. See
|
||||||
|
/// `install::backup_current_binary`'s doc comment for why this is a local
|
||||||
|
/// snapshot rather than a re-fetch of the old version from the server.
|
||||||
|
pub fn backup_dir(pkg_name: &str, version: &str) -> PathBuf {
|
||||||
|
state_base_dir()
|
||||||
|
.join("bakery/backups")
|
||||||
|
.join(pkg_name)
|
||||||
|
.join(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -113,6 +141,8 @@ mod tests {
|
||||||
services: vec![],
|
services: vec![],
|
||||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
track: Track::Stable,
|
track: Track::Stable,
|
||||||
|
previous_version: None,
|
||||||
|
binary_sha256: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,6 +196,8 @@ mod tests {
|
||||||
services: vec!["bar.service".to_string()],
|
services: vec!["bar.service".to_string()],
|
||||||
installed_at: "2026-06-01T00:00:00Z".to_string(),
|
installed_at: "2026-06-01T00:00:00Z".to_string(),
|
||||||
track: Track::Beta,
|
track: Track::Beta,
|
||||||
|
previous_version: Some("1.0.0".to_string()),
|
||||||
|
binary_sha256: HashMap::from([("bar".to_string(), "abc123".to_string())]),
|
||||||
});
|
});
|
||||||
let json = serde_json::to_string(&state).unwrap();
|
let json = serde_json::to_string(&state).unwrap();
|
||||||
let restored: State = serde_json::from_str(&json).unwrap();
|
let restored: State = serde_json::from_str(&json).unwrap();
|
||||||
|
|
@ -173,6 +205,8 @@ mod tests {
|
||||||
assert_eq!(restored.packages["bar"].version, "2.0.0");
|
assert_eq!(restored.packages["bar"].version, "2.0.0");
|
||||||
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
|
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
|
||||||
assert_eq!(restored.packages["bar"].track, Track::Beta);
|
assert_eq!(restored.packages["bar"].track, Track::Beta);
|
||||||
|
assert_eq!(restored.packages["bar"].previous_version.as_deref(), Some("1.0.0"));
|
||||||
|
assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -184,6 +218,26 @@ mod tests {
|
||||||
assert_eq!(installed.track, Track::Stable);
|
assert_eq!(installed.track, Track::Stable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn installed_package_previous_version_and_binary_sha256_default_on_old_shape_json() {
|
||||||
|
// Simulates an installed.json entry written before rollback/verify
|
||||||
|
// support existed.
|
||||||
|
let old_shape = r#"{"name":"foo","version":"1.0.0","binaries":[],"services":[],"installed_at":"2026-01-01T00:00:00Z","track":"stable"}"#;
|
||||||
|
let installed: InstalledPackage = serde_json::from_str(old_shape).unwrap();
|
||||||
|
assert!(installed.previous_version.is_none());
|
||||||
|
assert!(installed.binary_sha256.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn backup_dir_is_distinct_per_package_and_version() {
|
||||||
|
let a = backup_dir("bakery", "0.3.1");
|
||||||
|
let b = backup_dir("bakery", "0.3.2");
|
||||||
|
let c = backup_dir("breadhelp", "0.3.1");
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert_ne!(a, c);
|
||||||
|
assert!(a.ends_with("bakery/backups/bakery/0.3.1"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn with_lock_persists_mutation_across_reload() {
|
fn with_lock_persists_mutation_across_reload() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|
|
||||||
|
|
@ -9,20 +9,15 @@ use std::str::FromStr;
|
||||||
/// pacman) documented in `docs/release-channels.md` — that's an orthogonal,
|
/// pacman) documented in `docs/release-channels.md` — that's an orthogonal,
|
||||||
/// pre-existing use of the word "channel", which is why this is called a
|
/// pre-existing use of the word "channel", which is why this is called a
|
||||||
/// "track" instead.
|
/// "track" instead.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, ValueEnum)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize, ValueEnum)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum Track {
|
pub enum Track {
|
||||||
|
#[default]
|
||||||
Stable,
|
Stable,
|
||||||
Beta,
|
Beta,
|
||||||
Dev,
|
Dev,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Track {
|
|
||||||
fn default() -> Self {
|
|
||||||
Track::Stable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Track {
|
impl Track {
|
||||||
pub fn as_str(&self) -> &'static str {
|
pub fn as_str(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,16 @@ pub fn fail(s: &str) -> String {
|
||||||
style(&format!("✗ {s}"), RED)
|
style(&format!("✗ {s}"), RED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Neutral "nothing to do" glyph, dim rather than green — for steady-state
|
||||||
|
/// noise like "already at latest" in `bakery update --all`, where most
|
||||||
|
/// packages hit this every run. Reusing GREEN there drowns out the
|
||||||
|
/// packages that actually changed, and meaning shouldn't depend on color
|
||||||
|
/// alone (an unusual terminal palette can make BOLD/GREEN/DIM look similar),
|
||||||
|
/// so this also carries its own glyph the way `ok`/`fail` do.
|
||||||
|
pub fn unchanged(s: &str) -> String {
|
||||||
|
style(&format!("· {s}"), DIM)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -57,4 +67,14 @@ mod tests {
|
||||||
fn dev_badge_is_nonempty() {
|
fn dev_badge_is_nonempty() {
|
||||||
assert!(!track_badge(Track::Dev).is_empty());
|
assert!(!track_badge(Track::Dev).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unchanged_carries_a_distinct_glyph_from_ok_and_fail() {
|
||||||
|
// Meaning must survive even with colors stripped (NO_COLOR, or a
|
||||||
|
// terminal palette that makes ANSI codes look alike) — so the glyph
|
||||||
|
// itself has to differ, not just the color.
|
||||||
|
assert!(unchanged("foo").contains('·'));
|
||||||
|
assert!(!ok("foo").contains('·'));
|
||||||
|
assert!(!fail("foo").contains('·'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue