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
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
36
Cargo.lock
generated
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
for cmd in &pkg.post_install {
|
// strings, so this is gated behind --no-hooks and an interactive
|
||||||
run_hook(cmd, &pkg.name)?;
|
// 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.
|
// 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.record(InstalledPackage {
|
State::with_lock(|state| {
|
||||||
name: pkg.name.clone(),
|
state.record(InstalledPackage {
|
||||||
version: pkg.version.clone(),
|
name: pkg.name.clone(),
|
||||||
binaries: binary_names,
|
version: pkg.version.clone(),
|
||||||
services: service_names,
|
binaries: binary_names,
|
||||||
installed_at: chrono::Utc::now().to_rfc3339(),
|
services: service_names,
|
||||||
});
|
installed_at: chrono::Utc::now().to_rfc3339(),
|
||||||
state.save()?;
|
track,
|
||||||
|
});
|
||||||
|
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,44 +376,99 @@ 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.
|
||||||
std::fs::write(&unit_path, &bytes)
|
match fetch_and_verify_unit(pkg, svc) {
|
||||||
.with_context(|| format!("writing {}", unit_path.display()))?;
|
Ok(bytes) => {
|
||||||
println!(" downloaded unit {}", unit_path.display());
|
std::fs::write(&unit_path, &bytes)
|
||||||
}
|
.with_context(|| format!("writing {}", unit_path.display()))?;
|
||||||
Err(e) => {
|
println!(" downloaded unit {}", unit_path.display());
|
||||||
eprintln!(
|
}
|
||||||
" warning: checksum mismatch for unit {}: {e} — not installed",
|
Err(e) => {
|
||||||
svc.unit
|
if had_existing {
|
||||||
);
|
eprintln!(
|
||||||
}
|
" warning: could not refresh unit {} ({e}) — keeping existing copy",
|
||||||
},
|
svc.unit
|
||||||
Err(e) => {
|
);
|
||||||
eprintln!(" warning: could not download {}: {e}", svc.unit);
|
} else {
|
||||||
}
|
eprintln!(
|
||||||
}
|
" warning: unit file {} not found ({e}) — skipping service setup",
|
||||||
} else {
|
svc.unit
|
||||||
eprintln!(" warning: no artifact URL to download {}", 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)?;
|
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");
|
||||||
|
|
|
||||||
|
|
@ -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.set_track(track);
|
state::State::with_lock(|state| {
|
||||||
state.save()?;
|
state.set_track(track);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!(
|
if track_switch {
|
||||||
"updating {pkg_name} {} {} {}",
|
println!(
|
||||||
ui::style(&installed.version, ui::DIM),
|
"{pkg_name} switching track {} {} {}, installing {}",
|
||||||
ui::style("→", ui::CYAN),
|
ui::style(installed.track.as_str(), ui::DIM),
|
||||||
ui::style(&latest.version, ui::BOLD)
|
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) {
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue