From eba8cb6c44414bc97785bf0c5e8bce153f98822d Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 5 Aug 2026 17:10:31 +0800 Subject: [PATCH] bakery: add search, completions, rollback, verify, purge, dry-run, self-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New CLI surface, approved for review before merge: - search : case-insensitive name/description substring match - completions : bash/zsh/fish/elvish/powershell via clap_complete - rollback : 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. --- Cargo.lock | 10 + bakery/Cargo.toml | 1 + bakery/src/download.rs | 9 +- bakery/src/install.rs | 172 +++++++++++++- bakery/src/main.rs | 503 ++++++++++++++++++++++++++++++++++++++--- bakery/src/manifest.rs | 62 ++++- bakery/src/state.rs | 68 +++++- bakery/src/track.rs | 9 +- bakery/src/ui.rs | 20 ++ 9 files changed, 785 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3391cd..2bef91d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,7 @@ dependencies = [ "bread-utils", "chrono", "clap", + "clap_complete", "dirs", "fs4", "hex", @@ -330,6 +331,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f84a88507dbd05c695f2cb5e8558e747179134005e9893882dec964190ed89" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.1" diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index 377a6fe..1ccd0fe 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -16,6 +16,7 @@ ureq = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } clap = { workspace = true } +clap_complete = "4" chrono = { workspace = true } minisign-verify = { workspace = true } semver = { workspace = true } diff --git a/bakery/src/download.rs b/bakery/src/download.rs index 0e53ef3..14a19fd 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -7,8 +7,11 @@ use crate::manifest::{fetch_binary, Binary}; /// Download a binary, verify its SHA-256, then atomically write it into /// place (fsynced, temp-in-same-dir-with-unique-name then rename — see /// `bread_utils::atomic`). Bails before touching `dest` if the checksum -/// fails. -pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> { +/// fails. Returns the verified hex sha256 so callers (`install:: +/// 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 { println!(" downloading {}…", binary.name); let bytes = fetch_binary(&binary.dl_url, &binary.github_url) .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)) .with_context(|| format!("placing binary at {}", dest.display()))?; println!(" installed {}", dest.display()); - Ok(()) + Ok(binary.sha256.clone()) } /// Verify that `bytes` hashes to `expected_hex` under SHA-256. diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 858edca..ad49b3d 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -1,4 +1,5 @@ use anyhow::{bail, Context, Result}; +use std::collections::HashMap; use std::io::IsTerminal; use std::path::{Path, PathBuf}; 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") } +/// 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( pkg: &Package, bin_dir: &Path, track: Track, + previous: Option<&InstalledPackage>, no_hooks: bool, assume_yes: bool, ) -> Result<()> { ensure_safe_component(&pkg.name, "package name")?; 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_sha256 = HashMap::new(); for bin in &pkg.binaries { ensure_safe_component(&bin.name, "binary name")?; let install_name = strip_arch_suffix(&bin.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_sha256.insert(install_name.to_string(), sha256); } // 2. Scaffold config dir + download example file. @@ -124,6 +140,8 @@ pub fn install_package( services: service_names, installed_at: chrono::Utc::now().to_rfc3339(), track, + previous_version: previous.map(|p| p.version.clone()), + binary_sha256, }); Ok(()) })?; @@ -133,7 +151,33 @@ pub fn install_package( 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///`, 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 = match installed { 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 cfg_dir.exists() { println!(" config preserved at {}", cfg_dir.display()); } } - let data_dir = dirs::data_dir() - .unwrap_or_else(|| PathBuf::from("~/.local/share")) - .join(pkg_name); - if data_dir.exists() { + + let share_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("~/.local/share")); + let data_dir = share_dir.join(pkg_name); + + 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()); } if !failures.is_empty() { - eprintln!(" failed to remove {} binary/binaries:", failures.len()); + eprintln!(" failed to remove {} item(s):", failures.len()); for f in &failures { 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"); 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) { + 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<()> { let dir = expand_tilde(&cfg.dir); std::fs::create_dir_all(&dir)?; @@ -521,7 +603,7 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> { .lines() .map(|line| { 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(); if let Some(bin_name) = argv.first().and_then(|p| Path::new(p).file_name()) { let new_path = bin_dir.join(bin_name); @@ -874,7 +956,7 @@ mod tests { let mut pkg = test_package(&base_url); pkg.name = "../evil".to_string(); 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")); } @@ -934,4 +1016,70 @@ mod tests { assert!(out.contains("Description=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()); + } } diff --git a/bakery/src/main.rs b/bakery/src/main.rs index 801d409..51c095e 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -7,9 +7,10 @@ mod track; mod ui; use anyhow::{bail, Context, Result}; -use clap::{Parser, Subcommand}; -use std::collections::HashSet; -use std::path::PathBuf; +use clap::{CommandFactory, Parser, Subcommand}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; use track::Track; #[derive(Parser)] @@ -26,6 +27,9 @@ struct Cli { /// Assume yes to interactive prompts #[arg(short = 'y', long = "yes", global = true)] yes: bool, + /// Show what would happen without downloading, writing, or touching state + #[arg(long, global = true)] + dry_run: bool, } #[derive(Subcommand)] @@ -35,9 +39,13 @@ enum Cmd { #[arg(required = true, num_args = 1..)] packages: Vec, }, - /// Remove an installed package (data files are never deleted) + /// Remove an installed package (config is never deleted) Remove { package: String, + /// Also remove the license file, desktop entry, and data dir + /// (~/.local/share//) — config is still preserved + #[arg(long)] + purge: bool, }, /// Update one or all installed packages Update { @@ -58,11 +66,31 @@ enum Cmd { Info { package: String, }, + /// Search package names and descriptions + Search { + query: String, + }, /// Check system dependencies for installed or requested packages Doctor { /// Package to check; omit to check all installed packages package: Option, }, + /// Verify installed binaries against the checksum recorded at install time + Verify { + /// Package to verify; omit to verify all installed packages + package: Option, + }, + /// 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) Track { #[command(subcommand)] @@ -90,27 +118,43 @@ fn main() -> Result<()> { let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir); let no_hooks = cli.no_hooks; let assume_yes = cli.yes; + let dry_run = cli.dry_run; let track = state::State::load()?.track; match cli.command { Cmd::Install { packages } => { let index = manifest::load(true, track)?; 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(()) } - 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.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::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::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), } } +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<()> { let state = state::State::load()?; match action { @@ -148,9 +192,10 @@ fn cmd_install( track: Track, no_hooks: bool, assume_yes: bool, + dry_run: bool, ) -> Result<()> { 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 @@ -163,6 +208,7 @@ fn install_with_deps( track: Track, no_hooks: bool, assume_yes: bool, + dry_run: bool, visited: &mut HashSet, ) -> Result<()> { if !visited.insert(name.to_string()) { @@ -177,16 +223,18 @@ fn install_with_deps( let state = state::State::load()?; for dep in pkg.bread_deps.clone() { if !state.is_installed(&dep) { - println!("installing bread dependency: {dep}"); - install_with_deps(index, &dep, bin_dir, track, no_hooks, assume_yes, visited)?; + println!("{} bread dependency: {dep}", if dry_run { "would install" } else { "installing" }); + 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 // doubles as the implicit upgrade path when the index has something // newer, so `bakery install ` is safe to run repeatedly instead of // 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) { println!( "{name} already installed at {} (index has {})", @@ -207,11 +255,41 @@ fn install_with_deps( 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(()); + } + + install::install_package(pkg, bin_dir, track, previous, no_hooks, assume_yes) } -fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool) -> Result<()> { - 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::>().join(", ") + ); + if !pkg.services.is_empty() { + println!( + " services: {}", + pkg.services.iter().map(|s| s.unit.as_str()).collect::>().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)] @@ -222,14 +300,14 @@ fn cmd_update( track: Track, no_hooks: bool, assume_yes: bool, + dry_run: bool, ) -> Result<()> { let index = manifest::load(true, track)?; let state = state::State::load()?; - let targets: Vec = if all || name.is_none() { - state.packages.keys().cloned().collect() - } else { - vec![name.unwrap().to_string()] + let targets: Vec = match name { + Some(n) if !all => vec![n.to_string()], + _ => state.packages.keys().cloned().collect(), }; if targets.is_empty() { @@ -238,6 +316,8 @@ fn cmd_update( } let mut any_failed = false; + let mut updated = 0u32; + let mut unchanged = 0u32; for pkg_name in &targets { let installed = match state.packages.get(pkg_name.as_str()) { Some(p) => p, @@ -263,7 +343,16 @@ fn cmd_update( // beta RC that shares a base version with the installed stable). let track_switch = installed.track != track; 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; } @@ -304,12 +393,31 @@ fn cmd_update( 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}"); 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 { 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 ]` 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<()> { 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(); names.sort(); for name in names { - let pkg = &index.packages[name]; - let tag = if state.is_installed(name) { - ui::style(&format!(" [installed {}]", state.packages[name].version), ui::GREEN) - } else { - String::new() - }; - println!(" {:<14} {:<10} — {}{}", pkg.name, pkg.version, pkg.description, tag); + print_index_entry(&index.packages[name], &state); + } + Ok(()) +} + +/// Case-insensitive substring match against a package's name or description +/// — 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(()) } @@ -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 // (e.g. a binary manually deleted after install) — also confirm // every binary this package recorded is still on disk. Existence - // only, not a checksum re-verification — that's the scope of a - // future `bakery verify` command. + // only, not a checksum re-verification — see `bakery verify` for that. if let Some(installed) = state.packages.get(pkg_name) { for bin in &installed.binaries { 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(()) } +#[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 = 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> { + 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)] mod tests { use super::*; + use std::fs; + use tempfile::tempdir; #[test] fn is_newer_detects_real_semver_increase() { @@ -533,4 +851,131 @@ mod tests { fn should_update_true_when_same_track_and_newer() { 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()); + } } diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 9739ffa..5b5f5aa 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -212,8 +212,8 @@ pub fn load(force_refresh: bool, track: Track) -> Result { } fn read_and_verify_cache( - cache_path: &PathBuf, - sig_cache_path: &PathBuf, + cache_path: &Path, + sig_cache_path: &Path, track: Track, ) -> Result { 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 { +fn fetch_and_cache(cache_path: &Path, sig_cache_path: &Path, track: Track) -> Result { 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> { /// 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> { - 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> { 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 = 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::*; diff --git a/bakery/src/state.rs b/bakery/src/state.rs index 759cd18..4e29055 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -17,6 +17,20 @@ pub struct InstalledPackage { // convention as `State.track` above. #[serde(default)] 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, + /// 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, } #[derive(Debug, Default, Deserialize, Serialize)] @@ -91,14 +105,28 @@ impl State { } } +fn state_base_dir() -> PathBuf { + dirs::state_dir().unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from("~")) + .join(".local/state") + }) +} + fn state_path() -> PathBuf { - dirs::state_dir() - .unwrap_or_else(|| { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from("~")) - .join(".local/state") - }) - .join("bakery/installed.json") + 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)] @@ -113,6 +141,8 @@ mod tests { services: vec![], installed_at: "2026-01-01T00:00:00Z".to_string(), track: Track::Stable, + previous_version: None, + binary_sha256: HashMap::new(), } } @@ -166,6 +196,8 @@ mod tests { services: vec!["bar.service".to_string()], installed_at: "2026-06-01T00:00:00Z".to_string(), 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 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"].services, ["bar.service"]); 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] @@ -184,6 +218,26 @@ mod tests { 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] fn with_lock_persists_mutation_across_reload() { let dir = tempfile::tempdir().unwrap(); diff --git a/bakery/src/track.rs b/bakery/src/track.rs index 1c16ce5..47ef25f 100644 --- a/bakery/src/track.rs +++ b/bakery/src/track.rs @@ -9,20 +9,15 @@ use std::str::FromStr; /// 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 /// "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")] pub enum Track { + #[default] Stable, Beta, Dev, } -impl Default for Track { - fn default() -> Self { - Track::Stable - } -} - impl Track { pub fn as_str(&self) -> &'static str { match self { diff --git a/bakery/src/ui.rs b/bakery/src/ui.rs index 8f99505..c1408f3 100644 --- a/bakery/src/ui.rs +++ b/bakery/src/ui.rs @@ -44,6 +44,16 @@ pub fn fail(s: &str) -> String { 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)] mod tests { use super::*; @@ -57,4 +67,14 @@ mod tests { fn dev_badge_is_nonempty() { 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('·')); + } }