bakery: fix correctness, reliability, and security issues from audit
Some checks failed
dev bread-theme / build (push) Successful in 17s
dev bakery / build (push) Has been cancelled

Track switches now always take effect on `update --all` instead of
silently no-op'ing or permanently refusing on strict semver comparison.
`remove` no longer aborts cleanup on the first failed binary removal,
orphaning the systemd unit. State reads/writes are now lock-protected
and go through fsync'd atomic writes (also fixes a temp-path collision
in binary installs). The index loader falls back to a stale-but-signed
cache instead of hard-failing offline. systemd units now re-fetch on
every update instead of freezing after first install. `doctor` now
flags missing recorded binaries.

Security hardening: path-traversal guard on all index-controlled
filenames, archive extraction now rejects symlink/traversal entries
before tar touches disk, archive temp files use secure unique paths,
post_install hooks are gated behind --no-hooks/confirmation, response
buffering is capped, empty-checksum downloads get a clear error, and
both stable-track CI workflows now hard-fail on a missing signing key
(matching the existing dev/rc guard) instead of silently publishing an
index next to a stale signature. gen-index.sh now publishes the index
and its signature atomically.

Also: bakery install on an already-installed package no longer
silently reinstalls/downgrades, cmd_update exits non-zero for unknown
packages, and the unused toml dependency is removed.
This commit is contained in:
Breadway 2026-08-05 13:55:57 +08:00
parent 620c5a1317
commit d45fc422f2
10 changed files with 624 additions and 137 deletions

View file

@ -59,7 +59,13 @@ jobs:
- name: regenerate index.json - name: regenerate index.json
env: env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: cd src && bash scripts/gen-index.sh run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)"
exit 1
fi
cd src && bash scripts/gen-index.sh
- name: upload to GitHub Release - name: upload to GitHub Release
env: env:

View file

@ -57,7 +57,13 @@ jobs:
- name: regenerate index.json - name: regenerate index.json
env: env:
MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }} MINISIGN_SEC_KEY: ${{ secrets.BAKERY_MINISIGN_SEC_KEY_PATH }}
run: cd src && bash scripts/gen-index.sh run: |
set -euo pipefail
if [ -z "${MINISIGN_SEC_KEY:-}" ]; then
echo "::error::BAKERY_MINISIGN_SEC_KEY_PATH secret not set — refusing to regenerate stable index.json unsigned (would leave a stale signature mismatched against fresh content and break bakery for everyone on the stable track)"
exit 1
fi
cd src && bash scripts/gen-index.sh
- name: upload to GitHub Release - name: upload to GitHub Release
env: env:

36
Cargo.lock generated
View file

@ -107,9 +107,11 @@ name = "bakery"
version = "0.3.1" version = "0.3.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-utils",
"chrono", "chrono",
"clap", "clap",
"dirs", "dirs",
"fs4",
"hex", "hex",
"minisign-verify", "minisign-verify",
"semver", "semver",
@ -117,7 +119,6 @@ dependencies = [
"serde_json", "serde_json",
"sha2", "sha2",
"tempfile", "tempfile",
"toml 0.8.23",
"ureq", "ureq",
] ]
@ -655,6 +656,16 @@ dependencies = [
"percent-encoding", "percent-encoding",
] ]
[[package]]
name = "fs4"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8"
dependencies = [
"rustix 0.38.44",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.33" version = "0.3.33"
@ -1325,6 +1336,12 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@ -1815,6 +1832,19 @@ dependencies = [
"semver", "semver",
] ]
[[package]]
name = "rustix"
version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@ -1824,7 +1854,7 @@ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys 0.12.1",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -2063,7 +2093,7 @@ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.3", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix 1.1.4",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]

View file

@ -11,7 +11,6 @@ repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
anyhow = { workspace = true } anyhow = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
toml = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
ureq = { workspace = true } ureq = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
@ -20,6 +19,6 @@ clap = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
minisign-verify = { workspace = true } minisign-verify = { workspace = true }
semver = { workspace = true } semver = { workspace = true }
bread-utils = { path = "../bread-utils" }
[dev-dependencies] fs4 = { version = "0.8", features = ["sync"] }
tempfile = "3" tempfile = "3"

View file

@ -4,8 +4,10 @@ use std::path::Path;
use crate::manifest::{fetch_binary, Binary}; use crate::manifest::{fetch_binary, Binary};
/// Download a binary to a temp path, verify its SHA-256, then atomically move /// Download a binary, verify its SHA-256, then atomically write it into
/// it into place. Bails before touching `dest` if the checksum fails. /// 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<()> { pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
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)
@ -14,20 +16,8 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
verify_sha256(&bytes, &binary.sha256) verify_sha256(&bytes, &binary.sha256)
.with_context(|| format!("checksum mismatch for {}", binary.name))?; .with_context(|| format!("checksum mismatch for {}", binary.name))?;
if let Some(dir) = dest.parent() { bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
std::fs::create_dir_all(dir)?; .with_context(|| format!("placing binary at {}", dest.display()))?;
}
let tmp = dest.with_extension("tmp");
std::fs::write(&tmp, &bytes).context("writing binary to tmp")?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
}
std::fs::rename(&tmp, dest).context("placing binary")?;
println!(" installed {}", dest.display()); println!(" installed {}", dest.display());
Ok(()) Ok(())
} }
@ -38,6 +28,9 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<()> {
/// [`fetch_and_place`]), and config-example / systemd-unit downloads in /// [`fetch_and_place`]), and config-example / systemd-unit downloads in
/// `install.rs` — so all downloaded artifacts get the same integrity check. /// `install.rs` — so all downloaded artifacts get the same integrity check.
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> { pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
if expected_hex.is_empty() {
bail!("index entry has no sha256 recorded — refusing to trust an unverifiable download");
}
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(bytes); hasher.update(bytes);
let actual = hex::encode(hasher.finalize()); let actual = hex::encode(hasher.finalize());
@ -80,4 +73,14 @@ mod tests {
let hash = sha256_hex(bytes); let hash = sha256_hex(bytes);
assert!(verify_sha256(bytes, &hash).is_ok()); assert!(verify_sha256(bytes, &hash).is_ok());
} }
#[test]
fn verify_missing_sha256_gives_a_clear_error() {
// gen-index.sh emits an empty sha256 string when a .sha256 sidecar
// is missing — must not fall through to the generic mismatch
// message ("expected: \n actual: <hex>"), which is confusing about
// what actually went wrong.
let err = verify_sha256(b"anything", "").unwrap_err();
assert!(err.to_string().contains("no sha256 recorded"));
}
} }

View file

@ -1,19 +1,62 @@
use anyhow::{Context, Result}; use anyhow::{bail, Context, Result};
use std::io::IsTerminal;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use crate::download::{fetch_and_place, verify_sha256}; use crate::download::{fetch_and_place, verify_sha256};
use crate::manifest::{fetch_binary, Package, Service}; use crate::manifest::{fetch_binary, Package, Service};
use crate::state::{InstalledPackage, State}; use crate::state::{InstalledPackage, State};
use crate::track::Track;
pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> { /// Rejects a filename that isn't a safe single path component — no `/`,
/// `\`, empty, `.`, or `..`. `bin.name`/`svc.unit`/`cfg.example`/`pkg.name`/
/// `license_file`/`desktop_file`/`data_archive` all come from the
/// minisign-verified index, so this is defense in depth (not exploitable
/// without a compromised signing key) rather than the primary guard — but
/// closing the path-traversal class is cheap enough to do anyway before any
/// of these are joined onto a fixed base directory.
fn ensure_safe_component(name: &str, what: &str) -> Result<()> {
if name.is_empty() || name == "." || name == ".." || name.contains('/') || name.contains('\\') {
bail!("refusing to install: {what} '{name}' is not a safe filename");
}
Ok(())
}
/// Prompts `prompt [y/N] ` and returns the answer. `assume_yes` (the global
/// `--yes` flag) skips the prompt entirely; otherwise, a non-tty stdin
/// (CI, piped input) answers "no" rather than blocking on a read that will
/// never resolve.
fn confirm(prompt: &str, assume_yes: bool) -> bool {
if assume_yes {
return true;
}
if !std::io::stdin().is_terminal() {
return false;
}
use std::io::Write;
print!("{prompt} [y/N] ");
std::io::stdout().flush().ok();
let mut buf = String::new();
std::io::stdin().read_line(&mut buf).ok();
matches!(buf.trim().to_lowercase().as_str(), "y" | "yes")
}
pub fn install_package(
pkg: &Package,
bin_dir: &Path,
track: Track,
no_hooks: bool,
assume_yes: bool,
) -> Result<()> {
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.
let mut binary_names = Vec::new(); let mut binary_names = Vec::new();
for bin in &pkg.binaries { for bin in &pkg.binaries {
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)?; fetch_and_place(bin, &dest)?;
binary_names.push(install_name.to_string()); binary_names.push(install_name.to_string());
} }
@ -45,46 +88,74 @@ pub fn install_package(pkg: &Package, bin_dir: &Path) -> Result<()> {
service_names.push(svc.unit.clone()); service_names.push(svc.unit.clone());
} }
// 7. Run post_install hooks. // 7. Run post_install hooks — arbitrary `sh -c` on index-controlled
// strings, so this is gated behind --no-hooks and an interactive
// confirmation rather than running unconditionally.
if !pkg.post_install.is_empty() {
if no_hooks {
println!(
" note: skipped {} post_install hook(s) for {} (--no-hooks)",
pkg.post_install.len(),
pkg.name
);
} else if confirm(
&format!(
" run {} post_install hook(s) for {}?",
pkg.post_install.len(),
pkg.name
),
assume_yes,
) {
for cmd in &pkg.post_install { for cmd in &pkg.post_install {
run_hook(cmd, &pkg.name)?; run_hook(cmd, &pkg.name)?;
} }
} else {
println!(" skipped post_install hooks for {} (declined)", pkg.name);
}
}
// 8. Record in state. // 8. Record in state, under an exclusive lock so a concurrent `bakery`
let mut state = State::load()?; // invocation can't clobber this install's record with its own.
State::with_lock(|state| {
state.record(InstalledPackage { state.record(InstalledPackage {
name: pkg.name.clone(), name: pkg.name.clone(),
version: pkg.version.clone(), version: pkg.version.clone(),
binaries: binary_names, binaries: binary_names,
services: service_names, services: service_names,
installed_at: chrono::Utc::now().to_rfc3339(), installed_at: chrono::Utc::now().to_rfc3339(),
track,
}); });
state.save()?; Ok(())
})?;
println!(" {} installed successfully", pkg.name); println!(" {} installed successfully", pkg.name);
warn_path_if_needed(bin_dir); warn_path_if_needed(bin_dir);
Ok(()) Ok(())
} }
pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> { pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool) -> Result<()> {
let mut state = State::load()?; let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
let installed = match state.remove(pkg_name) { let installed = match installed {
Some(p) => p, Some(p) => p,
None => { None => {
eprintln!("{pkg_name} is not installed"); eprintln!("{pkg_name} is not installed");
return Ok(()); return Ok(());
} }
}; };
// Commit removal immediately — file cleanup below is best-effort. // State is already committed by with_lock above — everything from here
state.save()?; // is best-effort file cleanup, and must all run even if part of it fails.
// Remove binaries. // Remove binaries. Collect failures instead of aborting on the first one
// so a stuck/permission-denied binary doesn't skip service removal and
// the config/data-preserved messages below.
let mut failures = Vec::new();
for bin in &installed.binaries { for bin in &installed.binaries {
let path = bin_dir.join(bin); let path = bin_dir.join(bin);
if path.exists() { if path.exists() {
std::fs::remove_file(&path) match std::fs::remove_file(&path) {
.with_context(|| format!("removing {}", path.display()))?; Ok(()) => println!(" removed {}", path.display()),
println!(" removed {}", path.display()); Err(e) => failures.push(format!("{}: {e}", path.display())),
}
} }
} }
@ -93,7 +164,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
let service_dir = systemd_user_dir(); let service_dir = systemd_user_dir();
for unit in &installed.services { for unit in &installed.services {
let unit_path = service_dir.join(unit); let unit_path = service_dir.join(unit);
if confirm_remove_unit(unit) { if confirm_remove_unit(unit, assume_yes) {
let _ = Command::new("systemctl") let _ = Command::new("systemctl")
.args(["--user", "disable", "--now", unit]) .args(["--user", "disable", "--now", unit])
.status(); .status();
@ -121,6 +192,14 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
println!(" data preserved at {}", data_dir.display()); println!(" data preserved at {}", data_dir.display());
} }
if !failures.is_empty() {
eprintln!(" failed to remove {} binary/binaries:", failures.len());
for f in &failures {
eprintln!(" {f}");
}
bail!("{pkg_name} removed from state, but some binaries could not be deleted");
}
println!(" {pkg_name} removed"); println!(" {pkg_name} removed");
Ok(()) Ok(())
} }
@ -130,6 +209,7 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu
std::fs::create_dir_all(&dir)?; std::fs::create_dir_all(&dir)?;
if let Some(example) = &cfg.example { if let Some(example) = &cfg.example {
ensure_safe_component(example, "config.example")?;
let dest = dir.join(example); let dest = dir.join(example);
if !dest.exists() { if !dest.exists() {
if let Some((primary, fallback)) = pkg.artifact_urls(example) { if let Some((primary, fallback)) = pkg.artifact_urls(example) {
@ -217,6 +297,7 @@ fn fetch_verify_write(
} }
fn install_license(pkg: &Package, filename: &str) -> Result<()> { fn install_license(pkg: &Package, filename: &str) -> Result<()> {
ensure_safe_component(filename, "license_file")?;
let dest = dirs::data_dir() let dest = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share")) .unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("licenses") .join("licenses")
@ -226,6 +307,7 @@ fn install_license(pkg: &Package, filename: &str) -> Result<()> {
} }
fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> { fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> {
ensure_safe_component(filename, "desktop_file")?;
let dest = dirs::data_dir() let dest = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share")) .unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("applications") .join("applications")
@ -234,6 +316,7 @@ fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> {
} }
fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> { fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> {
ensure_safe_component(filename, "data_archive")?;
let data_dir = dirs::data_dir() let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share")) .unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join(&pkg.name); .join(&pkg.name);
@ -253,7 +336,15 @@ fn fetch_extract_archive(
sha256: &Option<String>, sha256: &Option<String>,
dest_dir: &Path, dest_dir: &Path,
) -> Result<()> { ) -> Result<()> {
let tmp_archive = std::env::temp_dir().join(format!("bakery-{}-{filename}", pkg.name)); // A securely-named, process-unique temp file — the old
// `std::env::temp_dir().join(format!("bakery-{name}-{filename}"))` was a
// predictable path on a shared /tmp, so another local user could
// pre-plant a symlink there for `fetch_verify_write`'s write to follow.
let tmp_archive = tempfile::Builder::new()
.prefix(&format!("bakery-{}-", pkg.name))
.tempfile()
.context("creating temp file for archive download")?
.into_temp_path();
fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?; fetch_verify_write(pkg, filename, sha256, &tmp_archive, "data archive")?;
if !tmp_archive.exists() { if !tmp_archive.exists() {
@ -261,13 +352,21 @@ fn fetch_extract_archive(
return Ok(()); return Ok(());
} }
verify_archive_paths(&tmp_archive)?;
std::fs::create_dir_all(dest_dir)?; std::fs::create_dir_all(dest_dir)?;
let status = Command::new("tar") let status = Command::new("tar")
.args(["xzf", &tmp_archive.to_string_lossy(), "-C"]) .args([
"xzf",
&tmp_archive.to_string_lossy(),
"--no-same-owner",
"--no-same-permissions",
"-C",
])
.arg(dest_dir) .arg(dest_dir)
.status() .status()
.with_context(|| format!("running tar to extract {filename}"))?; .with_context(|| format!("running tar to extract {filename}"))?;
let _ = std::fs::remove_file(&tmp_archive); // `tmp_archive` (a `TempPath` guard) deletes the file when it drops here.
if status.success() { if status.success() {
println!(" extracted {filename} to {}", dest_dir.display()); println!(" extracted {filename} to {}", dest_dir.display());
@ -277,45 +376,100 @@ fn fetch_extract_archive(
Ok(()) Ok(())
} }
/// Lists `archive_path`'s contents via `tar tvf` and rejects the archive
/// outright (no extraction) if any entry is a symlink or has an unsafe path
/// (`..` component, or absolute). `--no-same-owner --no-same-permissions` on
/// the actual extraction covers ownership/permission escalation, but not a
/// symlink or `../` entry walking the extraction outside `dest_dir` — this
/// closes that gap before `tar` ever touches disk.
fn verify_archive_paths(archive_path: &Path) -> Result<()> {
let output = Command::new("tar")
.arg("tvf")
.arg(archive_path)
.output()
.context("listing archive contents")?;
if !output.status.success() {
bail!(
"tar tvf exited with {} listing archive contents — refusing to extract",
output.status
);
}
let listing = String::from_utf8_lossy(&output.stdout);
for line in listing.lines() {
if line.trim().is_empty() {
continue;
}
// tar -tvf: `<perms> <owner>/<group> <size> <date> <time> <path>`,
// with symlinks rendered as `<path> -> <target>`.
let perms = line.split_whitespace().next().unwrap_or("");
let is_symlink = perms.starts_with('l');
let mut rest = line;
for _ in 0..5 {
let trimmed = rest.trim_start();
let idx = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len());
rest = &trimmed[idx..];
}
let path_field = rest.trim_start();
let path = path_field.split(" -> ").next().unwrap_or(path_field).trim();
if is_symlink {
bail!("refusing to extract archive: entry '{path}' is a symlink");
}
if path.starts_with('/') || path.split('/').any(|c| c == "..") {
bail!("refusing to extract archive: entry '{path}' has an unsafe path");
}
}
Ok(())
}
/// Downloads and checksum-verifies `svc.unit`'s artifact.
fn fetch_and_verify_unit(pkg: &Package, svc: &Service) -> Result<Vec<u8>> {
let (primary, fallback) = pkg
.artifact_urls(&svc.unit)
.ok_or_else(|| anyhow::anyhow!("no artifact URL to download {}", svc.unit))?;
let bytes = fetch_binary(&primary, &fallback)?;
verify_sha256(&bytes, &svc.sha256)?;
Ok(bytes)
}
fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> { fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
ensure_safe_component(&svc.unit, "service unit")?;
let service_dir = systemd_user_dir(); let service_dir = systemd_user_dir();
std::fs::create_dir_all(&service_dir)?; std::fs::create_dir_all(&service_dir)?;
let unit_path = service_dir.join(&svc.unit); let unit_path = service_dir.join(&svc.unit);
let had_existing = unit_path.exists();
// Download the unit file if not already present. // Always re-fetch and overwrite — the old `if !unit_path.exists()` gate
if !unit_path.exists() { // meant `Environment=`/`Restart=`/etc changes in a new release never
if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) { // applied after the first install, unlike binaries (which always
match fetch_binary(&primary, &fallback) { // re-fetch via `fetch_and_place` on every install/update). If the fetch
Ok(bytes) => match verify_sha256(&bytes, &svc.sha256) { // or checksum fails, fall back to whatever's already on disk rather than
Ok(()) => { // regressing offline/flaky-network reliability.
match fetch_and_verify_unit(pkg, svc) {
Ok(bytes) => {
std::fs::write(&unit_path, &bytes) std::fs::write(&unit_path, &bytes)
.with_context(|| format!("writing {}", unit_path.display()))?; .with_context(|| format!("writing {}", unit_path.display()))?;
println!(" downloaded unit {}", unit_path.display()); println!(" downloaded unit {}", unit_path.display());
} }
Err(e) => { Err(e) => {
if had_existing {
eprintln!( eprintln!(
" warning: checksum mismatch for unit {}: {e} — not installed", " warning: could not refresh unit {} ({e}) — keeping existing copy",
svc.unit svc.unit
); );
}
},
Err(e) => {
eprintln!(" warning: could not download {}: {e}", svc.unit);
}
}
} else { } else {
eprintln!(" warning: no artifact URL to download {}", svc.unit);
}
}
if !unit_path.exists() {
eprintln!( eprintln!(
" warning: unit file {} not found — skipping service setup", " warning: unit file {} not found ({e}) — skipping service setup",
svc.unit svc.unit
); );
return Ok(()); return Ok(());
} }
}
}
patch_exec_start(&unit_path, bin_dir)?; patch_exec_start(&unit_path, bin_dir)?;
@ -408,13 +562,8 @@ fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn confirm_remove_unit(unit: &str) -> bool { fn confirm_remove_unit(unit: &str, assume_yes: bool) -> bool {
use std::io::{self, Write}; confirm(&format!(" remove systemd unit {unit}?"), assume_yes)
print!(" remove systemd unit {unit}? [y/N] ");
io::stdout().flush().ok();
let mut buf = String::new();
io::stdin().read_line(&mut buf).ok();
matches!(buf.trim().to_lowercase().as_str(), "y" | "yes")
} }
fn systemd_user_dir() -> PathBuf { fn systemd_user_dir() -> PathBuf {
@ -644,6 +793,105 @@ mod tests {
assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n"); assert_eq!(fs::read(&extracted).unwrap(), b"[[step]]\n");
} }
#[test]
fn fetch_extract_archive_rejects_symlink_entries() {
let src = tempdir().unwrap();
std::os::unix::fs::symlink("/etc/passwd", src.path().join("evil")).unwrap();
let archive_path = src.path().join("evil.tar.gz");
let status = Command::new("tar")
.args(["czf"])
.arg(&archive_path)
.args(["-C"])
.arg(src.path())
.arg("evil")
.status()
.unwrap();
assert!(status.success());
let archive_bytes = fs::read(&archive_path).unwrap();
let sha256_hex = hex::encode(sha2::Sha256::digest(&archive_bytes));
let base_url = serve_once_owned(archive_bytes);
let pkg = test_package(&base_url);
let dest_dir = tempdir().unwrap();
let err = fetch_extract_archive(&pkg, "evil.tar.gz", &Some(sha256_hex), dest_dir.path())
.unwrap_err();
assert!(err.to_string().contains("symlink"));
assert!(!dest_dir.path().join("evil").exists());
}
#[test]
fn fetch_extract_archive_rejects_parent_traversal_entries() {
// tar happily stores a `../`-prefixed member name if asked with -P
// (disable the security checks that would otherwise strip it) —
// this is the shape of archive our own pre-extraction check has to
// catch, since `--no-same-owner`/`--no-same-permissions` alone don't.
let src = tempdir().unwrap();
fs::create_dir_all(src.path().join("payload")).unwrap();
fs::write(src.path().join("payload/../escape.txt"), b"pwned").unwrap();
let archive_path = src.path().join("evil.tar.gz");
let status = Command::new("tar")
.args(["czf"])
.arg(&archive_path)
.args(["-C"])
.arg(src.path())
.arg("-P")
.arg("payload/../escape.txt")
.status()
.unwrap();
assert!(status.success());
let archive_bytes = fs::read(&archive_path).unwrap();
let sha256_hex = hex::encode(sha2::Sha256::digest(&archive_bytes));
let base_url = serve_once_owned(archive_bytes);
let pkg = test_package(&base_url);
let dest_dir = tempdir().unwrap();
let err = fetch_extract_archive(&pkg, "evil.tar.gz", &Some(sha256_hex), dest_dir.path())
.unwrap_err();
assert!(err.to_string().contains("unsafe path"));
}
#[test]
fn ensure_safe_component_accepts_plain_names() {
assert!(ensure_safe_component("breadhelp", "x").is_ok());
assert!(ensure_safe_component("LICENSE", "x").is_ok());
}
#[test]
fn ensure_safe_component_rejects_traversal_and_separators() {
assert!(ensure_safe_component("", "x").is_err());
assert!(ensure_safe_component(".", "x").is_err());
assert!(ensure_safe_component("..", "x").is_err());
assert!(ensure_safe_component("a/b", "x").is_err());
assert!(ensure_safe_component("a\\b", "x").is_err());
assert!(ensure_safe_component("../../etc/passwd", "x").is_err());
}
#[test]
fn install_package_rejects_unsafe_package_name() {
let base_url = serve_once(b"unused");
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();
assert!(err.to_string().contains("not a safe filename"));
}
#[test]
fn confirm_returns_true_when_assume_yes() {
assert!(confirm("anything", true));
}
#[test]
fn confirm_returns_false_when_not_assume_yes_and_stdin_not_a_tty() {
// The test harness's stdin is never an interactive terminal, so this
// must answer "no" rather than block on a read that never resolves
// (the CI-hang bug `confirm` replaces `confirm_remove_unit`'s old
// unconditional stdin read to fix).
assert!(!confirm("anything", false));
}
#[test] #[test]
fn strip_known_suffixes() { fn strip_known_suffixes() {
assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd"); assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd");

View file

@ -20,6 +20,12 @@ struct Cli {
/// Override the directory where binaries are installed /// Override the directory where binaries are installed
#[arg(long, env = "BAKERY_BIN_DIR", global = true)] #[arg(long, env = "BAKERY_BIN_DIR", global = true)]
bin_dir: Option<PathBuf>, bin_dir: Option<PathBuf>,
/// Skip post_install hooks entirely
#[arg(long, global = true)]
no_hooks: bool,
/// Assume yes to interactive prompts
#[arg(short = 'y', long = "yes", global = true)]
yes: bool,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@ -82,27 +88,31 @@ fn default_bin_dir() -> PathBuf {
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
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 assume_yes = cli.yes;
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)?; cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes)?;
} }
Ok(()) Ok(())
} }
Cmd::Remove { package } => cmd_remove(&package, &bin_dir), Cmd::Remove { package } => cmd_remove(&package, &bin_dir, assume_yes),
Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir, track), Cmd::Update { package, all } => {
cmd_update(package.as_deref(), all, &bin_dir, track, no_hooks, assume_yes)
}
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::Doctor { package } => cmd_doctor(package.as_deref(), track), Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir),
Cmd::Track { action } => cmd_track(action), Cmd::Track { action } => cmd_track(action),
} }
} }
fn cmd_track(action: TrackCmd) -> Result<()> { fn cmd_track(action: TrackCmd) -> Result<()> {
let mut state = state::State::load()?; let state = state::State::load()?;
match action { match action {
TrackCmd::Show => { TrackCmd::Show => {
println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN)); println!("current track: {}", ui::style(state.track.as_str(), ui::CYAN));
@ -116,8 +126,10 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
// recording a preference bakery can't actually serve. // recording a preference bakery can't actually serve.
manifest::load(true, track) manifest::load(true, track)
.with_context(|| format!("could not validate {track} track, not switching"))?; .with_context(|| format!("could not validate {track} track, not switching"))?;
state::State::with_lock(|state| {
state.set_track(track); state.set_track(track);
state.save()?; Ok(())
})?;
println!( println!(
"switched to {} — run 'bakery update --all' to install {} builds", "switched to {} — run 'bakery update --all' to install {} builds",
ui::style(track.as_str(), ui::CYAN), ui::style(track.as_str(), ui::CYAN),
@ -128,17 +140,29 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_install(index: &manifest::Index, name: &str, bin_dir: &std::path::Path) -> Result<()> { #[allow(clippy::too_many_arguments)]
fn cmd_install(
index: &manifest::Index,
name: &str,
bin_dir: &std::path::Path,
track: Track,
no_hooks: bool,
assume_yes: bool,
) -> Result<()> {
let mut visited = HashSet::new(); let mut visited = HashSet::new();
install_with_deps(index, name, bin_dir, &mut visited) install_with_deps(index, name, bin_dir, track, no_hooks, assume_yes, &mut visited)
} }
/// Recursively installs `name` and any bread_deps, skipping already-installed /// Recursively installs `name` and any bread_deps, skipping already-installed
/// packages. The `visited` set prevents cycles. /// packages. The `visited` set prevents cycles.
#[allow(clippy::too_many_arguments)]
fn install_with_deps( fn install_with_deps(
index: &manifest::Index, index: &manifest::Index,
name: &str, name: &str,
bin_dir: &std::path::Path, bin_dir: &std::path::Path,
track: Track,
no_hooks: bool,
assume_yes: bool,
visited: &mut HashSet<String>, visited: &mut HashSet<String>,
) -> Result<()> { ) -> Result<()> {
if !visited.insert(name.to_string()) { if !visited.insert(name.to_string()) {
@ -154,7 +178,21 @@ fn install_with_deps(
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!("installing bread dependency: {dep}");
install_with_deps(index, &dep, bin_dir, visited)?; install_with_deps(index, &dep, bin_dir, track, no_hooks, assume_yes, visited)?;
}
}
// 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 <pkg>` is safe to run repeatedly instead of
// silently reinstalling (and potentially downgrading) every time.
if let Some(installed) = state.packages.get(name) {
if !is_newer(&installed.version, &pkg.version) {
println!(
"{name} already installed at {} (index has {})",
installed.version, pkg.version
);
return Ok(());
} }
} }
@ -169,14 +207,22 @@ fn install_with_deps(
bail!("system deps not satisfied"); bail!("system deps not satisfied");
} }
install::install_package(pkg, bin_dir) install::install_package(pkg, bin_dir, track, no_hooks, assume_yes)
} }
fn cmd_remove(name: &str, bin_dir: &std::path::Path) -> Result<()> { fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool) -> Result<()> {
install::remove_package(name, bin_dir) install::remove_package(name, bin_dir, assume_yes)
} }
fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: Track) -> Result<()> { #[allow(clippy::too_many_arguments)]
fn cmd_update(
name: Option<&str>,
all: bool,
bin_dir: &std::path::Path,
track: Track,
no_hooks: bool,
assume_yes: bool,
) -> Result<()> {
let index = manifest::load(true, track)?; let index = manifest::load(true, track)?;
let state = state::State::load()?; let state = state::State::load()?;
@ -197,6 +243,7 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: T
Some(p) => p, Some(p) => p,
None => { None => {
eprintln!("{pkg_name} is not installed, skipping"); eprintln!("{pkg_name} is not installed, skipping");
any_failed = true;
continue; continue;
} }
}; };
@ -204,21 +251,38 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: T
Some(p) => p, Some(p) => p,
None => { None => {
eprintln!("{pkg_name} not found in index, skipping"); eprintln!("{pkg_name} not found in index, skipping");
any_failed = true;
continue; continue;
} }
}; };
if !is_newer(&installed.version, &latest.version) { // A track switch is an explicit user action ("bakery track set beta
// && bakery update --all") and must always take effect, even if the
// new track's current build happens to be same-or-lower by strict
// semver than what's installed (e.g. switching stable -> dev, or a
// 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)); println!("{}", ui::style(&format!("{pkg_name} is already at {}", installed.version), ui::GREEN));
continue; continue;
} }
if track_switch {
println!(
"{pkg_name} switching track {} {} {}, installing {}",
ui::style(installed.track.as_str(), ui::DIM),
ui::style("", ui::CYAN),
ui::style(track.as_str(), ui::BOLD),
ui::style(&latest.version, ui::BOLD)
);
} else {
println!( println!(
"updating {pkg_name} {} {} {}", "updating {pkg_name} {} {} {}",
ui::style(&installed.version, ui::DIM), ui::style(&installed.version, ui::DIM),
ui::style("", ui::CYAN), ui::style("", ui::CYAN),
ui::style(&latest.version, ui::BOLD) ui::style(&latest.version, ui::BOLD)
); );
}
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) { let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
Ok(r) => r, Ok(r) => r,
@ -240,7 +304,7 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: T
continue; continue;
} }
if let Err(e) = install::install_package(latest, bin_dir) { if let Err(e) = install::install_package(latest, bin_dir, track, no_hooks, assume_yes) {
eprintln!(" failed to update {pkg_name}: {e}"); eprintln!(" failed to update {pkg_name}: {e}");
any_failed = true; any_failed = true;
} }
@ -252,6 +316,16 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: T
Ok(()) Ok(())
} }
/// Whether `pkg_name` should be updated: always true on a track switch
/// (an explicit user action that must take effect regardless of version
/// ordering), otherwise a real semver comparison via [`is_newer`].
fn should_update(installed_version: &str, installed_track: Track, active_track: Track, latest_version: &str) -> bool {
if installed_track != active_track {
return true;
}
is_newer(installed_version, latest_version)
}
/// Is `latest` newer than `installed`? Real semver comparison — the /// Is `latest` newer than `installed`? Real semver comparison — the
/// previous plain string-equality check couldn't tell "different" from /// previous plain string-equality check couldn't tell "different" from
/// "actually newer", so it would happily "update" a package to a lexically /// "actually newer", so it would happily "update" a package to a lexically
@ -264,8 +338,8 @@ fn is_newer(installed: &str, latest: &str) -> bool {
_ => { _ => {
if installed != latest { if installed != latest {
eprintln!( eprintln!(
" warning: '{installed}' or '{latest}' is not valid semver, \ " warning: cannot determine if '{latest}' is newer than '{installed}' \
falling back to a plain inequality check" (not valid semver) proceeding on inequality alone, this could be a downgrade"
); );
} }
installed != latest installed != latest
@ -351,7 +425,7 @@ fn cmd_info(name: &str, track: Track) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_doctor(name: Option<&str>, track: Track) -> Result<()> { fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Result<()> {
let index = manifest::load(false, track)?; let index = manifest::load(false, track)?;
let state = state::State::load()?; let state = state::State::load()?;
@ -380,6 +454,27 @@ fn cmd_doctor(name: Option<&str>, track: Track) -> Result<()> {
eprintln!(" {pkg_name}: not found in index (removed from registry?)"); eprintln!(" {pkg_name}: not found in index (removed from registry?)");
all_ok = false; all_ok = false;
} }
// 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.
if let Some(installed) = state.packages.get(pkg_name) {
for bin in &installed.binaries {
let path = bin_dir.join(bin);
if !path.exists() {
eprintln!(
" {}",
ui::fail(&format!(
"{pkg_name}: recorded binary '{bin}' is missing at {}",
path.display()
))
);
all_ok = false;
}
}
}
} }
if all_ok { if all_ok {
@ -420,4 +515,22 @@ mod tests {
assert!(is_newer("weird-version-1", "weird-version-2")); assert!(is_newer("weird-version-1", "weird-version-2"));
assert!(!is_newer("weird-version-1", "weird-version-1")); assert!(!is_newer("weird-version-1", "weird-version-1"));
} }
#[test]
fn should_update_true_on_track_switch_even_if_not_newer_by_semver() {
// "bakery track set stable && bakery update --all" from beta must
// always take effect, even though 0.3.0 < 0.4.0-beta by strict semver.
assert!(should_update("0.4.0-beta", Track::Beta, Track::Stable, "0.3.0"));
}
#[test]
fn should_update_false_when_same_track_and_not_newer() {
assert!(!should_update("0.3.1", Track::Stable, Track::Stable, "0.3.1"));
assert!(!should_update("0.3.2", Track::Dev, Track::Dev, "0.3.1"));
}
#[test]
fn should_update_true_when_same_track_and_newer() {
assert!(should_update("0.3.1", Track::Stable, Track::Stable, "0.3.2"));
}
} }

View file

@ -190,7 +190,25 @@ pub fn load(force_refresh: bool, track: Track) -> Result<Index> {
} }
} }
fetch_and_cache(&cache_path, &sig_cache_path, track) match fetch_and_cache(&cache_path, &sig_cache_path, track) {
Ok(index) => Ok(index),
Err(fetch_err) => {
// A network error shouldn't be a hard failure when a valid
// signed cache is sitting right there on disk, even if it's
// stale (or freshness was never checked because force_refresh
// was set) — fall back to it rather than bricking the CLI.
match read_and_verify_cache(&cache_path, &sig_cache_path, track) {
Ok(index) => {
eprintln!(
" warning: could not refresh {track} index ({fetch_err}) — \
using possibly-stale cached index"
);
Ok(index)
}
Err(_) => Err(fetch_err),
}
}
}
} }
fn read_and_verify_cache( fn read_and_verify_cache(
@ -227,11 +245,10 @@ fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track)
verify_index_signature(&bytes, &sig_text) verify_index_signature(&bytes, &sig_text)
.with_context(|| format!("freshly fetched {track} index failed signature verification"))?; .with_context(|| format!("freshly fetched {track} index failed signature verification"))?;
if let Some(dir) = cache_path.parent() { bread_utils::atomic::write_atomic_bytes(cache_path, &bytes, None)
std::fs::create_dir_all(dir)?; .with_context(|| format!("writing cached {track} index"))?;
} bread_utils::atomic::write_atomic_bytes(sig_cache_path, sig_text.as_bytes(), None)
std::fs::write(cache_path, &bytes)?; .with_context(|| format!("writing cached {track} index signature"))?;
std::fs::write(sig_cache_path, &sig_text)?;
serde_json::from_slice(&bytes).context("parsing index.json") serde_json::from_slice(&bytes).context("parsing index.json")
} }
@ -242,11 +259,8 @@ fn sig_cache_path(cache_path: &Path) -> PathBuf {
} }
fn fetch_text(url: &str) -> Result<String> { fn fetch_text(url: &str) -> Result<String> {
ureq::get(url) let bytes = fetch_bytes(url)?;
.call() String::from_utf8(bytes).context("response is not valid UTF-8")
.map_err(|e| anyhow::anyhow!("{e}"))?
.into_string()
.context("reading response body")
} }
/// Cache filename for `track`. `Stable` keeps the pre-track filename /// Cache filename for `track`. `Stable` keeps the pre-track filename
@ -279,6 +293,10 @@ pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
} }
} }
/// Comfortably above any real bakery artifact — caps how much of a response
/// gets buffered into memory before any trust check runs on it.
const MAX_RESPONSE_BYTES: u64 = 256 * 1024 * 1024;
fn fetch_bytes(url: &str) -> Result<Vec<u8>> { fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
use std::io::Read; use std::io::Read;
let resp = ureq::get(url) let resp = ureq::get(url)
@ -290,8 +308,12 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
} }
let mut buf = Vec::new(); let mut buf = Vec::new();
resp.into_reader() resp.into_reader()
.take(MAX_RESPONSE_BYTES + 1)
.read_to_end(&mut buf) .read_to_end(&mut buf)
.context("reading response")?; .context("reading response")?;
if buf.len() as u64 > MAX_RESPONSE_BYTES {
bail!("response from {url} exceeds the {MAX_RESPONSE_BYTES}-byte limit");
}
Ok(buf) Ok(buf)
} }

View file

@ -1,5 +1,6 @@
use crate::track::Track; use crate::track::Track;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use fs4::FileExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
@ -11,6 +12,11 @@ pub struct InstalledPackage {
pub binaries: Vec<String>, pub binaries: Vec<String>,
pub services: Vec<String>, pub services: Vec<String>,
pub installed_at: String, pub installed_at: String,
// `#[serde(default)]` so an installed.json written before per-package
// track tracking existed still deserializes — defaults to Stable, same
// convention as `State.track` above.
#[serde(default)]
pub track: Track,
} }
#[derive(Debug, Default, Deserialize, Serialize)] #[derive(Debug, Default, Deserialize, Serialize)]
@ -35,16 +41,37 @@ impl State {
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let path = state_path(); let path = state_path();
if let Some(dir) = path.parent() { let text = serde_json::to_string_pretty(self)?;
bread_utils::atomic::write_atomic(&path, &text, None)
.context("writing installed.json")
}
/// Runs `f` against a freshly-loaded `State` while holding an exclusive
/// lock on a sibling `installed.json.lock` file, saving the result if `f`
/// succeeds. Without this, two concurrent `bakery` invocations each
/// load-mutate-save `installed.json` independently and the second save
/// silently drops the first's change — the lock serializes the whole
/// read-modify-write instead of just the final write.
pub fn with_lock<T>(f: impl FnOnce(&mut State) -> Result<T>) -> Result<T> {
let lock_path = PathBuf::from(format!("{}.lock", state_path().display()));
if let Some(dir) = lock_path.parent() {
std::fs::create_dir_all(dir)?; std::fs::create_dir_all(dir)?;
} }
let text = serde_json::to_string_pretty(self)?; let lock_file = std::fs::OpenOptions::new()
// Write to a temp file then rename for atomicity — avoids a torn write .create(true)
// if the process is killed mid-save. .write(true)
let tmp = path.with_extension("tmp"); .truncate(false)
std::fs::write(&tmp, &text).context("writing installed.json.tmp")?; .open(&lock_path)
std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?; .context("opening installed.json.lock")?;
Ok(()) lock_file
.lock_exclusive()
.context("locking installed.json.lock")?;
let mut state = Self::load()?;
let result = f(&mut state)?;
state.save()?;
// Lock releases when `lock_file` drops at end of scope.
Ok(result)
} }
pub fn is_installed(&self, name: &str) -> bool { pub fn is_installed(&self, name: &str) -> bool {
@ -85,6 +112,7 @@ mod tests {
binaries: vec![], binaries: vec![],
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,
} }
} }
@ -137,11 +165,41 @@ mod tests {
binaries: vec!["bar".to_string()], binaries: vec!["bar".to_string()],
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,
}); });
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();
assert!(restored.is_installed("bar")); assert!(restored.is_installed("bar"));
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);
}
#[test]
fn installed_package_track_defaults_to_stable_on_old_shape_json() {
// Simulates an installed.json entry written before per-package track
// tracking existed.
let old_shape = r#"{"name":"foo","version":"1.0.0","binaries":[],"services":[],"installed_at":"2026-01-01T00:00:00Z"}"#;
let installed: InstalledPackage = serde_json::from_str(old_shape).unwrap();
assert_eq!(installed.track, Track::Stable);
}
#[test]
fn with_lock_persists_mutation_across_reload() {
let dir = tempfile::tempdir().unwrap();
// SAFETY (test-only): temporarily redirects the state dir env var so
// this test doesn't touch the real ~/.local/state/bakery/installed.json.
std::env::set_var("XDG_STATE_HOME", dir.path());
State::with_lock(|state| {
state.record(pkg("foo", "1.0.0"));
Ok(())
})
.unwrap();
let reloaded = State::load().unwrap();
assert!(reloaded.is_installed("foo"));
std::env::remove_var("XDG_STATE_HOME");
} }
} }

View file

@ -333,7 +333,8 @@ jq -n \
--arg generated_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --arg generated_at "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--argjson packages "${packages_json}" \ --argjson packages "${packages_json}" \
'{version: $version, generated_at: $generated_at, packages: $packages}' \ '{version: $version, generated_at: $generated_at, packages: $packages}' \
> "${OUT}" > "${OUT}.tmp"
mv -f "${OUT}.tmp" "${OUT}"
echo "wrote ${OUT}" echo "wrote ${OUT}"
@ -360,7 +361,7 @@ if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then
echo "ERROR: MINISIGN_SEC_KEY is set but the 'minisign' binary is not installed" >&2 echo "ERROR: MINISIGN_SEC_KEY is set but the 'minisign' binary is not installed" >&2
exit 1 exit 1
fi fi
sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig") sign_args=(-S -s "${MINISIGN_SEC_KEY}" -m "${OUT}" -x "${OUT}.minisig.tmp")
if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then if [[ -n "${MINISIGN_SEC_KEY_PASSWORD:-}" ]]; then
MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" </dev/null MINISIGN_PASSWORD="${MINISIGN_SEC_KEY_PASSWORD}" minisign "${sign_args[@]}" </dev/null
else else
@ -368,6 +369,7 @@ if [[ -n "${MINISIGN_SEC_KEY:-}" ]]; then
# normally generated, since there's no human to type a passphrase). # normally generated, since there's no human to type a passphrase).
minisign -W "${sign_args[@]}" </dev/null minisign -W "${sign_args[@]}" </dev/null
fi fi
mv -f "${OUT}.minisig.tmp" "${OUT}.minisig"
echo "signed ${OUT} -> ${OUT}.minisig" echo "signed ${OUT} -> ${OUT}.minisig"
else else
echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2 echo "WARNING: MINISIGN_SEC_KEY not set — index.json was NOT signed." >&2