bakery: fix correctness, reliability, and security issues from audit
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:
parent
620c5a1317
commit
d45fc422f2
10 changed files with 624 additions and 137 deletions
|
|
@ -4,8 +4,10 @@ use std::path::Path;
|
|||
|
||||
use crate::manifest::{fetch_binary, Binary};
|
||||
|
||||
/// Download a binary to a temp path, verify its SHA-256, then atomically move
|
||||
/// it into place. Bails before touching `dest` if the checksum fails.
|
||||
/// 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<()> {
|
||||
println!(" downloading {}…", binary.name);
|
||||
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)
|
||||
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
|
||||
|
||||
if let Some(dir) = dest.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
|
||||
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")?;
|
||||
bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
|
||||
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
||||
println!(" installed {}", dest.display());
|
||||
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
|
||||
/// `install.rs` — so all downloaded artifacts get the same integrity check.
|
||||
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();
|
||||
hasher.update(bytes);
|
||||
let actual = hex::encode(hasher.finalize());
|
||||
|
|
@ -80,4 +73,14 @@ mod tests {
|
|||
let hash = sha256_hex(bytes);
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,62 @@
|
|||
use anyhow::{Context, Result};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::io::IsTerminal;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::download::{fetch_and_place, verify_sha256};
|
||||
use crate::manifest::{fetch_binary, Package, Service};
|
||||
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);
|
||||
|
||||
// 1. Download and verify all binaries.
|
||||
let mut binary_names = Vec::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);
|
||||
let dest = bin_dir.join(install_name);
|
||||
fetch_and_place(bin, &dest)?;
|
||||
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());
|
||||
}
|
||||
|
||||
// 7. Run post_install hooks.
|
||||
for cmd in &pkg.post_install {
|
||||
run_hook(cmd, &pkg.name)?;
|
||||
// 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 {
|
||||
run_hook(cmd, &pkg.name)?;
|
||||
}
|
||||
} else {
|
||||
println!(" skipped post_install hooks for {} (declined)", pkg.name);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Record in state.
|
||||
let mut state = State::load()?;
|
||||
state.record(InstalledPackage {
|
||||
name: pkg.name.clone(),
|
||||
version: pkg.version.clone(),
|
||||
binaries: binary_names,
|
||||
services: service_names,
|
||||
installed_at: chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
state.save()?;
|
||||
// 8. Record in state, under an exclusive lock so a concurrent `bakery`
|
||||
// invocation can't clobber this install's record with its own.
|
||||
State::with_lock(|state| {
|
||||
state.record(InstalledPackage {
|
||||
name: pkg.name.clone(),
|
||||
version: pkg.version.clone(),
|
||||
binaries: binary_names,
|
||||
services: service_names,
|
||||
installed_at: chrono::Utc::now().to_rfc3339(),
|
||||
track,
|
||||
});
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
println!(" {} installed successfully", pkg.name);
|
||||
warn_path_if_needed(bin_dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
|
||||
let mut state = State::load()?;
|
||||
let installed = match state.remove(pkg_name) {
|
||||
pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool) -> Result<()> {
|
||||
let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
|
||||
let installed = match installed {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("{pkg_name} is not installed");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
// Commit removal immediately — file cleanup below is best-effort.
|
||||
state.save()?;
|
||||
// State is already committed by with_lock above — everything from here
|
||||
// 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 {
|
||||
let path = bin_dir.join(bin);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(&path)
|
||||
.with_context(|| format!("removing {}", path.display()))?;
|
||||
println!(" removed {}", path.display());
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => 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();
|
||||
for unit in &installed.services {
|
||||
let unit_path = service_dir.join(unit);
|
||||
if confirm_remove_unit(unit) {
|
||||
if confirm_remove_unit(unit, assume_yes) {
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "disable", "--now", unit])
|
||||
.status();
|
||||
|
|
@ -121,6 +192,14 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path) -> Result<()> {
|
|||
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");
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -130,6 +209,7 @@ fn scaffold_config(cfg: &crate::manifest::ConfigScaffold, pkg: &Package) -> Resu
|
|||
std::fs::create_dir_all(&dir)?;
|
||||
|
||||
if let Some(example) = &cfg.example {
|
||||
ensure_safe_component(example, "config.example")?;
|
||||
let dest = dir.join(example);
|
||||
if !dest.exists() {
|
||||
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<()> {
|
||||
ensure_safe_component(filename, "license_file")?;
|
||||
let dest = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("licenses")
|
||||
|
|
@ -226,6 +307,7 @@ fn install_license(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()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join("applications")
|
||||
|
|
@ -234,6 +316,7 @@ fn install_desktop_file(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()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
||||
.join(&pkg.name);
|
||||
|
|
@ -253,7 +336,15 @@ fn fetch_extract_archive(
|
|||
sha256: &Option<String>,
|
||||
dest_dir: &Path,
|
||||
) -> 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")?;
|
||||
if !tmp_archive.exists() {
|
||||
|
|
@ -261,13 +352,21 @@ fn fetch_extract_archive(
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
verify_archive_paths(&tmp_archive)?;
|
||||
|
||||
std::fs::create_dir_all(dest_dir)?;
|
||||
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)
|
||||
.status()
|
||||
.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() {
|
||||
println!(" extracted {filename} to {}", dest_dir.display());
|
||||
|
|
@ -277,44 +376,99 @@ fn fetch_extract_archive(
|
|||
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<()> {
|
||||
ensure_safe_component(&svc.unit, "service unit")?;
|
||||
|
||||
let service_dir = systemd_user_dir();
|
||||
std::fs::create_dir_all(&service_dir)?;
|
||||
|
||||
let unit_path = service_dir.join(&svc.unit);
|
||||
let had_existing = unit_path.exists();
|
||||
|
||||
// Download the unit file if not already present.
|
||||
if !unit_path.exists() {
|
||||
if let Some((primary, fallback)) = pkg.artifact_urls(&svc.unit) {
|
||||
match fetch_binary(&primary, &fallback) {
|
||||
Ok(bytes) => match verify_sha256(&bytes, &svc.sha256) {
|
||||
Ok(()) => {
|
||||
std::fs::write(&unit_path, &bytes)
|
||||
.with_context(|| format!("writing {}", unit_path.display()))?;
|
||||
println!(" downloaded unit {}", unit_path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
" warning: checksum mismatch for unit {}: {e} — not installed",
|
||||
svc.unit
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!(" warning: could not download {}: {e}", svc.unit);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
eprintln!(" warning: no artifact URL to download {}", svc.unit);
|
||||
// Always re-fetch and overwrite — the old `if !unit_path.exists()` gate
|
||||
// meant `Environment=`/`Restart=`/etc changes in a new release never
|
||||
// applied after the first install, unlike binaries (which always
|
||||
// re-fetch via `fetch_and_place` on every install/update). If the fetch
|
||||
// or checksum fails, fall back to whatever's already on disk rather than
|
||||
// regressing offline/flaky-network reliability.
|
||||
match fetch_and_verify_unit(pkg, svc) {
|
||||
Ok(bytes) => {
|
||||
std::fs::write(&unit_path, &bytes)
|
||||
.with_context(|| format!("writing {}", unit_path.display()))?;
|
||||
println!(" downloaded unit {}", unit_path.display());
|
||||
}
|
||||
Err(e) => {
|
||||
if had_existing {
|
||||
eprintln!(
|
||||
" warning: could not refresh unit {} ({e}) — keeping existing copy",
|
||||
svc.unit
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
" warning: unit file {} not found ({e}) — skipping service setup",
|
||||
svc.unit
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !unit_path.exists() {
|
||||
eprintln!(
|
||||
" warning: unit file {} not found — skipping service setup",
|
||||
svc.unit
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
patch_exec_start(&unit_path, bin_dir)?;
|
||||
|
|
@ -408,13 +562,8 @@ fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn confirm_remove_unit(unit: &str) -> bool {
|
||||
use std::io::{self, Write};
|
||||
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 confirm_remove_unit(unit: &str, assume_yes: bool) -> bool {
|
||||
confirm(&format!(" remove systemd unit {unit}?"), assume_yes)
|
||||
}
|
||||
|
||||
fn systemd_user_dir() -> PathBuf {
|
||||
|
|
@ -644,6 +793,105 @@ mod tests {
|
|||
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]
|
||||
fn strip_known_suffixes() {
|
||||
assert_eq!(strip_arch_suffix("breadd-x86_64"), "breadd");
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ struct Cli {
|
|||
/// Override the directory where binaries are installed
|
||||
#[arg(long, env = "BAKERY_BIN_DIR", global = true)]
|
||||
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)]
|
||||
|
|
@ -82,27 +88,31 @@ fn default_bin_dir() -> PathBuf {
|
|||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
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;
|
||||
|
||||
match cli.command {
|
||||
Cmd::Install { packages } => {
|
||||
let index = manifest::load(true, track)?;
|
||||
for pkg in &packages {
|
||||
cmd_install(&index, pkg, &bin_dir)?;
|
||||
cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Remove { package } => cmd_remove(&package, &bin_dir),
|
||||
Cmd::Update { package, all } => cmd_update(package.as_deref(), all, &bin_dir, track),
|
||||
Cmd::Remove { package } => cmd_remove(&package, &bin_dir, assume_yes),
|
||||
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::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),
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_track(action: TrackCmd) -> Result<()> {
|
||||
let mut state = state::State::load()?;
|
||||
let state = state::State::load()?;
|
||||
match action {
|
||||
TrackCmd::Show => {
|
||||
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.
|
||||
manifest::load(true, track)
|
||||
.with_context(|| format!("could not validate {track} track, not switching"))?;
|
||||
state.set_track(track);
|
||||
state.save()?;
|
||||
state::State::with_lock(|state| {
|
||||
state.set_track(track);
|
||||
Ok(())
|
||||
})?;
|
||||
println!(
|
||||
"switched to {} — run 'bakery update --all' to install {} builds",
|
||||
ui::style(track.as_str(), ui::CYAN),
|
||||
|
|
@ -128,17 +140,29 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
|||
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();
|
||||
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
|
||||
/// packages. The `visited` set prevents cycles.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn install_with_deps(
|
||||
index: &manifest::Index,
|
||||
name: &str,
|
||||
bin_dir: &std::path::Path,
|
||||
track: Track,
|
||||
no_hooks: bool,
|
||||
assume_yes: bool,
|
||||
visited: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
if !visited.insert(name.to_string()) {
|
||||
|
|
@ -154,7 +178,21 @@ fn install_with_deps(
|
|||
for dep in pkg.bread_deps.clone() {
|
||||
if !state.is_installed(&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");
|
||||
}
|
||||
|
||||
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<()> {
|
||||
install::remove_package(name, bin_dir)
|
||||
fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool) -> Result<()> {
|
||||
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 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,
|
||||
None => {
|
||||
eprintln!("{pkg_name} is not installed, skipping");
|
||||
any_failed = true;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
|
@ -204,21 +251,38 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: T
|
|||
Some(p) => p,
|
||||
None => {
|
||||
eprintln!("{pkg_name} not found in index, skipping");
|
||||
any_failed = true;
|
||||
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));
|
||||
continue;
|
||||
}
|
||||
|
||||
println!(
|
||||
"updating {pkg_name} {} {} {}",
|
||||
ui::style(&installed.version, ui::DIM),
|
||||
ui::style("→", ui::CYAN),
|
||||
ui::style(&latest.version, ui::BOLD)
|
||||
);
|
||||
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!(
|
||||
"updating {pkg_name} {} {} {}",
|
||||
ui::style(&installed.version, ui::DIM),
|
||||
ui::style("→", ui::CYAN),
|
||||
ui::style(&latest.version, ui::BOLD)
|
||||
);
|
||||
}
|
||||
|
||||
let rep = match doctor::check_deps(&latest.system_deps, &latest.optional_system_deps) {
|
||||
Ok(r) => r,
|
||||
|
|
@ -240,7 +304,7 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: T
|
|||
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}");
|
||||
any_failed = true;
|
||||
}
|
||||
|
|
@ -252,6 +316,16 @@ fn cmd_update(name: Option<&str>, all: bool, bin_dir: &std::path::Path, track: T
|
|||
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
|
||||
/// previous plain string-equality check couldn't tell "different" from
|
||||
/// "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 {
|
||||
eprintln!(
|
||||
" warning: '{installed}' or '{latest}' is not valid semver, \
|
||||
falling back to a plain inequality check"
|
||||
" warning: cannot determine if '{latest}' is newer than '{installed}' \
|
||||
(not valid semver) — proceeding on inequality alone, this could be a downgrade"
|
||||
);
|
||||
}
|
||||
installed != latest
|
||||
|
|
@ -351,7 +425,7 @@ fn cmd_info(name: &str, track: Track) -> Result<()> {
|
|||
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 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?)");
|
||||
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 {
|
||||
|
|
@ -420,4 +515,22 @@ mod tests {
|
|||
assert!(is_newer("weird-version-1", "weird-version-2"));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -227,11 +245,10 @@ fn fetch_and_cache(cache_path: &PathBuf, sig_cache_path: &PathBuf, track: Track)
|
|||
verify_index_signature(&bytes, &sig_text)
|
||||
.with_context(|| format!("freshly fetched {track} index failed signature verification"))?;
|
||||
|
||||
if let Some(dir) = cache_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(cache_path, &bytes)?;
|
||||
std::fs::write(sig_cache_path, &sig_text)?;
|
||||
bread_utils::atomic::write_atomic_bytes(cache_path, &bytes, None)
|
||||
.with_context(|| format!("writing cached {track} index"))?;
|
||||
bread_utils::atomic::write_atomic_bytes(sig_cache_path, sig_text.as_bytes(), None)
|
||||
.with_context(|| format!("writing cached {track} index signature"))?;
|
||||
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> {
|
||||
ureq::get(url)
|
||||
.call()
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?
|
||||
.into_string()
|
||||
.context("reading response body")
|
||||
let bytes = fetch_bytes(url)?;
|
||||
String::from_utf8(bytes).context("response is not valid UTF-8")
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
use std::io::Read;
|
||||
let resp = ureq::get(url)
|
||||
|
|
@ -290,8 +308,12 @@ fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
|
|||
}
|
||||
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");
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::track::Track;
|
||||
use anyhow::{Context, Result};
|
||||
use fs4::FileExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
|
@ -11,6 +12,11 @@ pub struct InstalledPackage {
|
|||
pub binaries: Vec<String>,
|
||||
pub services: Vec<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)]
|
||||
|
|
@ -35,16 +41,37 @@ impl State {
|
|||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
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)?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(self)?;
|
||||
// Write to a temp file then rename for atomicity — avoids a torn write
|
||||
// if the process is killed mid-save.
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, &text).context("writing installed.json.tmp")?;
|
||||
std::fs::rename(&tmp, &path).context("atomically replacing installed.json")?;
|
||||
Ok(())
|
||||
let lock_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&lock_path)
|
||||
.context("opening installed.json.lock")?;
|
||||
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 {
|
||||
|
|
@ -85,6 +112,7 @@ mod tests {
|
|||
binaries: vec![],
|
||||
services: vec![],
|
||||
installed_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
track: Track::Stable,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -137,11 +165,41 @@ mod tests {
|
|||
binaries: vec!["bar".to_string()],
|
||||
services: vec!["bar.service".to_string()],
|
||||
installed_at: "2026-06-01T00:00:00Z".to_string(),
|
||||
track: Track::Beta,
|
||||
});
|
||||
let json = serde_json::to_string(&state).unwrap();
|
||||
let restored: State = serde_json::from_str(&json).unwrap();
|
||||
assert!(restored.is_installed("bar"));
|
||||
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);
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue