bakery: add system prefix installs for BOS
All checks were successful
dev bread-theme / build (push) Successful in 20s
dev bakery / build (push) Successful in 51s

Default remains ~/.local. Setting prefix=/usr/local (via
/etc/bakery/config.toml or BAKERY_PREFIX) installs bins and share
under that prefix and systemd user units under /usr/lib/systemd/user.
Writes that need root use sudo -n, then pkexec. State stays per-user.
This commit is contained in:
Breadway 2026-08-16 00:00:19 +08:00
parent b20e4bcec1
commit c296d26408
11 changed files with 738 additions and 118 deletions

View file

@ -19,3 +19,6 @@ Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work
## Don't
- Don't embed credentials in remote URLs — SSH or a credential helper only.
- Don't flip bakery's default install prefix. System prefix (`/usr/local` via
`/etc/bakery/config.toml` or `BAKERY_PREFIX`) is for BOS; hermes and
`get.sh` stay on `~/.local`. See [`bakery/README.md`](bakery/README.md).

1
Cargo.lock generated
View file

@ -120,6 +120,7 @@ dependencies = [
"serde_json",
"sha2",
"tempfile",
"toml 0.8.23",
"ureq",
]

View file

@ -106,6 +106,26 @@ bakery remove <pkg> # remove a package (data files are never deleted)
`bakery install` runs `doctor` first and bails with a clear message if any system dependency is missing. Binaries land in `~/.local/bin` (override with `BAKERY_BIN_DIR`).
## System prefix (BOS)
Default install root is `~/.local`. BOS sets a system prefix so bakery-managed
desktop apps live on the `@` root subvolume and ride along with
snapper/grub-btrfs snapshots:
```toml
# /etc/bakery/config.toml
prefix = "/usr/local"
```
`BAKERY_PREFIX` overrides the config file. A non-home prefix installs bins to
`$prefix/bin`, share/data/desktop/licenses to `$prefix/share/...`, and systemd
user units to `/usr/lib/systemd/user`. Per-user state (`installed.json`,
update backups) stays in `~/.local/state/bakery`. Writes that need root use
`sudo -n`, then `pkexec`. `bakery doctor` prints the active prefix.
Hermes and `get.sh` are unchanged — they keep the user-local default. See
[`bakery/README.md`](bakery/README.md).
## System dependencies by product
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.

View file

@ -11,6 +11,7 @@ repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
toml = { workspace = true }
dirs = { workspace = true }
ureq = { workspace = true }
sha2 = { workspace = true }

30
bakery/README.md Normal file
View file

@ -0,0 +1,30 @@
# bakery
Package manager for the bread ecosystem. Usage lives in the
[repo README](../README.md).
## Install prefix
Default root is `~/.local` (bins in `~/.local/bin`, data in
`~/.local/share`). That is the hermes / `get.sh` path and must stay the
default.
BOS sets a system prefix so bakery-managed desktop apps live on the `@`
root subvolume and are included in snapper/grub-btrfs snapshots:
```toml
# /etc/bakery/config.toml
prefix = "/usr/local"
```
`BAKERY_PREFIX` overrides the config file. A non-home prefix installs:
| Thing | Path |
|-------|------|
| bins | `$prefix/bin` |
| share / desktop / licenses / data | `$prefix/share/...` |
| systemd user units | `/usr/lib/systemd/user` |
Per-user state (`installed.json` and pre-update backups) stays in
`~/.local/state/bakery`. Writes that need root use `sudo -n`, then
`pkexec`. `bakery doctor` prints the active prefix.

View file

@ -20,7 +20,7 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
verify_sha256(&bytes, &binary.sha256)
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
bread_utils::atomic::write_atomic_bytes(dest, &bytes, Some(0o755))
crate::prefix::write_bytes(dest, &bytes, 0o755)
.with_context(|| format!("placing binary at {}", dest.display()))?;
ui::step("placed", &dest.display().to_string());
Ok(binary.sha256.clone())

View file

@ -7,6 +7,7 @@ use std::process::Command;
use crate::download::{fetch_and_place, verify_sha256};
use crate::manifest::{fetch_binary, Package, Service};
use crate::prefix::{self, Layout};
use crate::state::{InstalledPackage, State};
use crate::track::Track;
use crate::ui;
@ -68,7 +69,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
/// install" for the pre-overwrite backup below.
pub fn install_package(
pkg: &Package,
bin_dir: &Path,
layout: &Layout,
track: Track,
previous: Option<&InstalledPackage>,
no_hooks: bool,
@ -78,14 +79,17 @@ pub fn install_package(
// 1. Download and verify all binaries. On an update (not a fresh
// install), back up the current binary first — best-effort, feeding
// `bakery rollback` — before it's overwritten below.
// `bakery rollback` — before it's overwritten below. Backups stay
// per-user under ~/.local/state even when the live binary is in
// /usr/local/bin, so a snapper snapshot of `@` plus this local copy
// is enough to roll back; no second snapshot system.
let backup_dir = previous.map(|prev| crate::state::backup_dir(&pkg.name, &prev.version));
let mut binary_names = Vec::new();
let mut binary_sha256 = HashMap::new();
for bin in &pkg.binaries {
ensure_safe_component(&bin.name, "binary name")?;
let install_name = strip_arch_suffix(&bin.name);
let dest = bin_dir.join(install_name);
let dest = layout.bin_dir.join(install_name);
if let Some(dir) = &backup_dir {
backup_current_binary(dir, install_name, &dest);
}
@ -94,30 +98,32 @@ pub fn install_package(
binary_sha256.insert(install_name.to_string(), sha256);
}
// 2. Scaffold config dir + download example file.
// 2. Scaffold config dir + download example file. Config stays
// per-user (~/.config) regardless of prefix — it's authored content,
// not bakery-placed bits.
if let Some(cfg) = &pkg.config {
scaffold_config(cfg, pkg)?;
}
// 3. Install license file, if declared.
if let Some(license) = &pkg.license_file {
install_license(pkg, license)?;
install_license(pkg, license, layout)?;
}
// 4. Install desktop entry, if declared.
if let Some(desktop) = &pkg.desktop_file {
install_desktop_file(pkg, desktop)?;
install_desktop_file(pkg, desktop, layout)?;
}
// 5. Download + extract data archive, if declared.
if let Some(archive) = &pkg.data_archive {
install_data_archive(pkg, archive)?;
install_data_archive(pkg, archive, layout)?;
}
// 6. Install systemd user units.
let mut service_names = Vec::new();
for svc in &pkg.services {
install_service(svc, bin_dir, pkg)?;
install_service(svc, layout, pkg)?;
service_names.push(svc.unit.clone());
}
@ -173,7 +179,7 @@ pub fn install_package(
})?;
println!(" {}", ui::ok(&format!("{} installed", pkg.name)));
warn_path_if_needed(bin_dir);
warn_path_if_needed(&layout.bin_dir);
Ok(())
}
@ -211,7 +217,12 @@ fn backup_current_binary(backup_dir: &Path, binary_name: &str, current_path: &Pa
}
}
pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: bool) -> Result<()> {
pub fn remove_package(
pkg_name: &str,
layout: &Layout,
assume_yes: bool,
purge: bool,
) -> Result<()> {
let installed = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
let installed = match installed {
Some(p) => p,
@ -229,9 +240,9 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
// the config/data-preserved messages below.
let mut failures = Vec::new();
for bin in &installed.binaries {
let path = bin_dir.join(bin);
let path = layout.bin_dir.join(bin);
if path.exists() {
match std::fs::remove_file(&path) {
match prefix::remove_file(&path) {
Ok(()) => ui::step("removed", &path.display().to_string()),
Err(e) => failures.push(format!("{}: {e}", path.display())),
}
@ -240,7 +251,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
// Prompt for unit removal.
if !installed.services.is_empty() {
let service_dir = systemd_user_dir();
let service_dir = &layout.systemd_user_dir;
for unit in &installed.services {
let unit_path = service_dir.join(unit);
if confirm_remove_unit(unit, assume_yes) {
@ -248,7 +259,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
.args(["--user", "disable", "--now", unit])
.status();
if unit_path.exists() {
std::fs::remove_file(&unit_path).ok();
let _ = prefix::remove_file(&unit_path);
}
let _ = Command::new("systemctl")
.args(["--user", "daemon-reload"])
@ -269,7 +280,7 @@ pub fn remove_package(pkg_name: &str, bin_dir: &Path, assume_yes: bool, purge: b
}
}
let share_dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"));
let share_dir = &layout.share_dir;
let data_dir = share_dir.join(pkg_name);
if purge {
@ -331,9 +342,9 @@ fn remove_purged_path(
return;
}
let result = if recursive {
std::fs::remove_dir_all(path)
prefix::remove_dir_all(path)
} else {
std::fs::remove_file(path)
prefix::remove_file(path)
};
match result {
Ok(()) => ui::step("removed", &path.display().to_string()),
@ -451,28 +462,26 @@ fn fetch_verify_write(
);
return Ok(());
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
prefix::write_bytes(dest, &bytes, 0o644)
.with_context(|| format!("writing {}", dest.display()))?;
ui::step("installed", &format!("{label} {}", dest.display()));
Ok(())
}
fn install_license(pkg: &Package, filename: &str) -> Result<()> {
fn install_license(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> {
ensure_safe_component(filename, "license_file")?;
let dest = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
let dest = layout
.share_dir
.join("licenses")
.join(&pkg.name)
.join("LICENSE");
fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "license")
}
fn install_desktop_file(pkg: &Package, filename: &str) -> Result<()> {
fn install_desktop_file(pkg: &Package, filename: &str, layout: &Layout) -> Result<()> {
ensure_safe_component(filename, "desktop_file")?;
let dest = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
let dest = layout
.share_dir
.join("applications")
.join(format!("{}.desktop", pkg.name));
fetch_verify_write(
@ -484,11 +493,9 @@ 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, layout: &Layout) -> Result<()> {
ensure_safe_component(filename, "data_archive")?;
let data_dir = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join(&pkg.name);
let data_dir = layout.share_dir.join(&pkg.name);
fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir)
}
@ -497,7 +504,7 @@ fn install_data_archive(pkg: &Package, filename: &str) -> Result<()> {
/// crate dependency — `tar` is universally present on Linux and this file
/// already shells out to `systemctl` for the same "trust the base system
/// has this" reason. Split from `install_data_archive` (which just supplies
/// the real `~/.local/share/<name>` destination) so tests can extract into
/// the real `$prefix/share/<name>` destination) so tests can extract into
/// a tempdir instead.
fn fetch_extract_archive(
pkg: &Package,
@ -523,31 +530,19 @@ fn fetch_extract_archive(
verify_archive_paths(&tmp_archive)?;
std::fs::create_dir_all(dest_dir)?;
let status = Command::new("tar")
.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}"))?;
// `tmp_archive` (a `TempPath` guard) deletes the file when it drops here.
if status.success() {
ui::step(
match prefix::extract_tar_gz(&tmp_archive, dest_dir) {
Ok(()) => ui::step(
"extracted",
&format!("{filename}{}", dest_dir.display()),
);
} else {
),
Err(e) => {
eprintln!(
" {}",
ui::warn(&format!("tar exited with {status} extracting {filename}"))
ui::warn(&format!("could not extract {filename}: {e}"))
);
}
}
// `tmp_archive` (a `TempPath` guard) deletes the file when it drops here.
Ok(())
}
@ -609,11 +604,11 @@ fn fetch_and_verify_unit(pkg: &Package, svc: &Service) -> Result<Vec<u8>> {
Ok(bytes)
}
fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
fn install_service(svc: &Service, layout: &Layout, pkg: &Package) -> Result<()> {
ensure_safe_component(&svc.unit, "service unit")?;
let service_dir = systemd_user_dir();
std::fs::create_dir_all(&service_dir)?;
let service_dir = &layout.systemd_user_dir;
prefix::create_dir_all(service_dir)?;
let unit_path = service_dir.join(&svc.unit);
let had_existing = unit_path.exists();
@ -623,10 +618,14 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
// 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.
// regressing offline/flaky-network reliability. Patch ExecStart in
// memory before the write so a system-prefix install only needs one
// privileged write, not write-then-rewrite.
match fetch_and_verify_unit(pkg, svc) {
Ok(bytes) => {
std::fs::write(&unit_path, &bytes)
let text = String::from_utf8_lossy(&bytes);
let patched = patch_exec_start_text(&text, &layout.bin_dir);
prefix::write_bytes(&unit_path, patched.as_bytes(), 0o644)
.with_context(|| format!("writing {}", unit_path.display()))?;
ui::step("unit", &unit_path.display().to_string());
}
@ -639,6 +638,7 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
svc.unit
))
);
patch_exec_start(&unit_path, &layout.bin_dir)?;
} else {
eprintln!(
" {}",
@ -652,8 +652,6 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
}
}
patch_exec_start(&unit_path, bin_dir)?;
if !Command::new("systemctl")
.args(["--user", "daemon-reload"])
.status()
@ -698,6 +696,12 @@ fn install_service(svc: &Service, bin_dir: &Path, pkg: &Package) -> Result<()> {
fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
let text = std::fs::read_to_string(unit_path)?;
let output = patch_exec_start_text(&text, bin_dir);
prefix::write_bytes(unit_path, output.as_bytes(), 0o644)?;
Ok(())
}
fn patch_exec_start_text(text: &str, bin_dir: &Path) -> String {
let patched: String = text
.lines()
.map(|line| {
@ -721,14 +725,11 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
})
.collect::<Vec<_>>()
.join("\n");
// Preserve trailing newline if the original had one.
let output = if text.ends_with('\n') {
if text.ends_with('\n') {
format!("{patched}\n")
} else {
patched
};
std::fs::write(unit_path, output)?;
Ok(())
}
}
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
@ -747,12 +748,6 @@ fn confirm_remove_unit(unit: &str, assume_yes: bool) -> bool {
confirm(&format!(" remove systemd unit {unit}?"), assume_yes)
}
fn systemd_user_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("systemd/user")
}
fn guess_config_dir(pkg_name: &str) -> Option<PathBuf> {
Some(dirs::config_dir()?.join(pkg_name))
}
@ -1063,7 +1058,8 @@ mod tests {
let mut pkg = test_package(&base_url);
pkg.name = "../evil".to_string();
let dir = tempdir().unwrap();
let err = install_package(&pkg, dir.path(), Track::Stable, None, true, true).unwrap_err();
let layout = Layout::from_prefix(dir.path(), None);
let err = install_package(&pkg, &layout, Track::Stable, None, true, true).unwrap_err();
assert!(err.to_string().contains("not a safe filename"));
}

View file

@ -2,6 +2,7 @@ mod doctor;
mod download;
mod install;
mod manifest;
mod prefix;
mod state;
mod track;
mod ui;
@ -48,7 +49,7 @@ enum Cmd {
Remove {
package: String,
/// Also remove the license file, desktop entry, and data dir
/// (~/.local/share/<pkg>/) — config is still preserved
/// ($prefix/share/<pkg>/) — config is still preserved
#[arg(long)]
purge: bool,
},
@ -104,15 +105,9 @@ enum TrackCmd {
Set { track: Track },
}
fn default_bin_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".local/bin")
}
fn main() -> Result<()> {
let cli = Cli::parse();
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
let layout = prefix::resolve(cli.bin_dir);
let no_hooks = cli.no_hooks;
let assume_yes = cli.yes;
let dry_run = cli.dry_run;
@ -122,15 +117,15 @@ fn main() -> Result<()> {
Cmd::Install { packages } => {
let index = manifest::load(true, track)?;
for pkg in &packages {
cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes, dry_run)?;
cmd_install(&index, pkg, &layout, track, no_hooks, assume_yes, dry_run)?;
}
Ok(())
}
Cmd::Remove { package, purge } => cmd_remove(&package, &bin_dir, assume_yes, purge),
Cmd::Remove { package, purge } => cmd_remove(&package, &layout, assume_yes, purge),
Cmd::Update { package, all } => cmd_update(
package.as_deref(),
all,
&bin_dir,
&layout,
track,
no_hooks,
assume_yes,
@ -139,9 +134,9 @@ fn main() -> Result<()> {
Cmd::List { installed } => cmd_list(installed, track),
Cmd::Info { package } => cmd_info(&package, track),
Cmd::Search { query } => cmd_search(&query, track),
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir),
Cmd::Verify { package } => cmd_verify(package.as_deref(), &bin_dir),
Cmd::Rollback { package } => cmd_rollback(&package, &bin_dir),
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &layout),
Cmd::Verify { package } => cmd_verify(package.as_deref(), &layout.bin_dir),
Cmd::Rollback { package } => cmd_rollback(&package, &layout.bin_dir),
// Same update logic as `bakery update bakery` — this is just a
// documented, discoverable entry point for it, since overwriting
// bakery's own running binary via a normal update already works
@ -150,7 +145,7 @@ fn main() -> Result<()> {
Cmd::SelfUpdate => cmd_update(
Some("bakery"),
false,
&bin_dir,
&layout,
track,
no_hooks,
assume_yes,
@ -199,7 +194,7 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
fn cmd_install(
index: &manifest::Index,
name: &str,
bin_dir: &std::path::Path,
layout: &prefix::Layout,
track: Track,
no_hooks: bool,
assume_yes: bool,
@ -209,7 +204,7 @@ fn cmd_install(
install_with_deps(
index,
name,
bin_dir,
layout,
track,
no_hooks,
assume_yes,
@ -224,7 +219,7 @@ fn cmd_install(
fn install_with_deps(
index: &manifest::Index,
name: &str,
bin_dir: &std::path::Path,
layout: &prefix::Layout,
track: Track,
no_hooks: bool,
assume_yes: bool,
@ -245,7 +240,7 @@ fn install_with_deps(
if !state.is_installed(&dep) {
ui::step(if dry_run { "would need" } else { "dependency" }, &dep);
install_with_deps(
index, &dep, bin_dir, track, no_hooks, assume_yes, dry_run, visited,
index, &dep, layout, track, no_hooks, assume_yes, dry_run, visited,
)?;
}
}
@ -315,7 +310,7 @@ fn install_with_deps(
return Ok(());
}
install::install_package(pkg, bin_dir, track, previous, no_hooks, assume_yes)
install::install_package(pkg, layout, track, previous, no_hooks, assume_yes)
}
/// Prints what `install_with_deps`/`cmd_update` would do for `pkg` under
@ -344,15 +339,15 @@ fn print_dry_run_plan(pkg: &manifest::Package) {
}
}
fn cmd_remove(name: &str, bin_dir: &std::path::Path, assume_yes: bool, purge: bool) -> Result<()> {
install::remove_package(name, bin_dir, assume_yes, purge)
fn cmd_remove(name: &str, layout: &prefix::Layout, assume_yes: bool, purge: bool) -> Result<()> {
install::remove_package(name, layout, assume_yes, purge)
}
#[allow(clippy::too_many_arguments)]
fn cmd_update(
name: Option<&str>,
all: bool,
bin_dir: &std::path::Path,
layout: &prefix::Layout,
track: Track,
no_hooks: bool,
assume_yes: bool,
@ -486,14 +481,9 @@ fn cmd_update(
continue;
}
if let Err(e) = install::install_package(
latest,
bin_dir,
track,
Some(installed),
no_hooks,
assume_yes,
) {
if let Err(e) =
install::install_package(latest, layout, track, Some(installed), no_hooks, assume_yes)
{
eprintln!(
" {}",
ui::fail(&format!("failed to update {pkg_name}: {e}"))
@ -705,7 +695,18 @@ fn cmd_info(name: &str, track: Track) -> Result<()> {
Ok(())
}
fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Result<()> {
fn report_layout(layout: &prefix::Layout) {
ui::kv(
"prefix",
&format!("{} ({})", layout.prefix.display(), layout.kind_label()),
);
ui::kv("bins", &layout.bin_dir.display().to_string());
ui::kv("share", &layout.share_dir.display().to_string());
ui::kv("units", &layout.systemd_user_dir.display().to_string());
ui::kv("state", &state::bakery_state_dir().display().to_string());
}
fn cmd_doctor(name: Option<&str>, track: Track, layout: &prefix::Layout) -> Result<()> {
let index = manifest::load(false, track)?;
let state = state::State::load()?;
@ -720,6 +721,9 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
};
if targets.is_empty() {
ui::heading("Doctor", &["no packages"]);
report_layout(layout);
println!();
println!("{}", ui::dim("no packages installed — nothing to check"));
return Ok(());
}
@ -727,6 +731,8 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
let mut targets = targets;
targets.sort();
ui::heading("Doctor", &[&format!("{} packages", targets.len())]);
report_layout(layout);
println!();
let name_w = ui::name_width(&targets);
let mut all_ok = true;
@ -756,7 +762,7 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
// only, not a checksum re-verification — see `bakery verify` for that.
if let Some(installed) = state.packages.get(pkg_name) {
for bin in &installed.binaries {
let path = bin_dir.join(bin);
let path = layout.bin_dir.join(bin);
if !path.exists() {
eprintln!(
" {}",
@ -906,7 +912,7 @@ fn restore_binaries(
.with_context(|| format!("reading backup {}", backup_path.display()))?;
let hash = hex::encode(Sha256::digest(&bytes));
let dest = bin_dir.join(bin);
bread_utils::atomic::write_atomic_bytes(&dest, &bytes, Some(0o755))
prefix::write_bytes(&dest, &bytes, 0o755)
.with_context(|| format!("restoring {}", dest.display()))?;
sha256.insert(bin.clone(), hash);
}
@ -1095,11 +1101,12 @@ mod tests {
};
let bin_dir = tempdir().unwrap();
let layout = prefix::Layout::from_prefix(bin_dir.path(), None);
let mut visited = HashSet::new();
install_with_deps(
&index,
name,
bin_dir.path(),
&layout,
Track::Stable,
true,
true,

View file

@ -107,21 +107,21 @@ pub struct Package {
#[serde(default)]
pub post_install: Vec<String>,
/// License artifact filename (e.g. "LICENSE"), installed to
/// `~/.local/share/licenses/<name>/LICENSE` — the bakery equivalent of
/// what a PKGBUILD's `package()` does with `/usr/share/licenses`.
/// `$prefix/share/licenses/<name>/LICENSE` (`~/.local/share/...` by
/// default) — the bakery equivalent of a PKGBUILD's `package()` step.
#[serde(default)]
pub license_file: Option<String>,
#[serde(default)]
pub license_file_sha256: Option<String>,
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
/// installed to `~/.local/share/applications/<name>.desktop` so the
/// app shows up in any XDG-compliant launcher without root.
/// installed to `$prefix/share/applications/<name>.desktop` so the
/// app shows up in any XDG-compliant launcher.
#[serde(default)]
pub desktop_file: Option<String>,
#[serde(default)]
pub desktop_file_sha256: Option<String>,
/// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz`
/// in the release dir, extracted to `~/.local/share/<name>/` on
/// in the release dir, extracted to `$prefix/share/<name>/` on
/// install. For arbitrary data a package needs at runtime beyond a
/// config example (e.g. breadhelp's guide content), where a single
/// downloadable file + `tar` extraction is simpler than teaching

546
bakery/src/prefix.rs Normal file
View file

@ -0,0 +1,546 @@
//! Install prefix: default `~/.local`, or a system root for BOS.
//!
//! Hermes and `get.sh` keep the user-local default. BOS sets
//! `prefix = "/usr/local"` in `/etc/bakery/config.toml` (or `BAKERY_PREFIX`)
//! so bakery-managed desktop apps live on the `@` root subvolume and ride
//! along with snapper/grub-btrfs snapshots. Per-user state stays under
//! `~/.local/state/bakery` either way — bakery still records what *this*
//! user asked for; the prefix only changes where bits land on disk.
//!
//! Writes that hit `EACCES` use `sudo -n` first, then `pkexec` if a
//! graphical session is available. Interactive `sudo` (password on stdin)
//! is never used — a GUI hook must not block on a TTY prompt.
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::ffi::OsStr;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
/// Default user-local prefix when no config/env override is set.
const DEFAULT_USER_PREFIX: &str = ".local";
/// System-wide user units, used when the prefix is not under `$HOME`.
const SYSTEM_USER_UNIT_DIR: &str = "/usr/lib/systemd/user";
const SYSTEM_CONFIG_PATH: &str = "/etc/bakery/config.toml";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Layout {
pub prefix: PathBuf,
pub bin_dir: PathBuf,
pub share_dir: PathBuf,
pub systemd_user_dir: PathBuf,
/// True when `prefix` is not under the user's home directory.
pub is_system: bool,
}
impl Layout {
pub fn kind_label(&self) -> &'static str {
if self.is_system {
"system"
} else {
"user"
}
}
/// Map a (possibly custom) prefix onto bin/share/unit paths.
/// `bin_override` is `--bin-dir` / `BAKERY_BIN_DIR` and wins for bins only.
pub fn from_prefix(prefix: &Path, bin_override: Option<PathBuf>) -> Self {
let prefix = normalize_prefix_path(prefix);
let is_system = is_system_prefix(&prefix);
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
let share_dir = prefix.join("share");
let systemd_user_dir = if is_system {
PathBuf::from(SYSTEM_USER_UNIT_DIR)
} else {
user_systemd_dir()
};
Self {
prefix,
bin_dir,
share_dir,
systemd_user_dir,
is_system,
}
}
/// Historical default: `~/.local` bins, XDG data dir for share,
/// `~/.config/systemd/user` for units. Used when neither `BAKERY_PREFIX`
/// nor `/etc/bakery/config.toml` sets a prefix — hermes / get.sh.
pub fn user_default(bin_override: Option<PathBuf>) -> Self {
let prefix = default_user_prefix();
let bin_dir = bin_override.unwrap_or_else(|| prefix.join("bin"));
let share_dir = dirs::data_dir().unwrap_or_else(|| prefix.join("share"));
Self {
prefix,
bin_dir,
share_dir,
systemd_user_dir: user_systemd_dir(),
is_system: false,
}
}
}
/// Resolve the active layout. `BAKERY_PREFIX` wins over `/etc/bakery/config.toml`;
/// neither set keeps the `~/.local` default. `bin_override` is the existing
/// `--bin-dir` / `BAKERY_BIN_DIR` knob.
pub fn resolve(bin_override: Option<PathBuf>) -> Layout {
let env = std::env::var("BAKERY_PREFIX").ok();
resolve_from(env.as_deref(), Path::new(SYSTEM_CONFIG_PATH), bin_override)
}
/// Same as [`resolve`] with the env value and config path injected, so
/// tests don't have to mutate process-global env or touch `/etc`.
pub fn resolve_from(
env_prefix: Option<&str>,
config_path: &Path,
bin_override: Option<PathBuf>,
) -> Layout {
match configured_prefix_from(env_prefix, config_path) {
Some(prefix) => Layout::from_prefix(&prefix, bin_override),
None => Layout::user_default(bin_override),
}
}
pub fn configured_prefix_from(env_prefix: Option<&str>, config_path: &Path) -> Option<PathBuf> {
if let Some(raw) = env_prefix {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return Some(normalize_prefix(trimmed));
}
}
load_config_prefix(config_path)
}
#[derive(Debug, Default, Deserialize)]
struct BakeryConfig {
prefix: Option<String>,
}
/// Reads `prefix = "..."` from a bakery config file. Missing file or empty
/// key → `None` (caller falls back to the user-local default). A file that
/// exists but fails to parse is warned about, not treated as fatal — a typo
/// in `/etc/bakery/config.toml` must not take down `bakery list`.
pub fn load_config_prefix(path: &Path) -> Option<PathBuf> {
if !path.exists() {
return None;
}
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
eprintln!(
" {}",
crate::ui::warn(&format!("could not read {}: {e}", path.display()))
);
return None;
}
};
match toml::from_str::<BakeryConfig>(&text) {
Ok(cfg) => cfg
.prefix
.as_deref()
.map(str::trim)
.filter(|p| !p.is_empty())
.map(normalize_prefix),
Err(e) => {
eprintln!(
" {}",
crate::ui::warn(&format!("could not parse {}: {e}", path.display()))
);
None
}
}
}
fn default_user_prefix() -> PathBuf {
home_dir().join(DEFAULT_USER_PREFIX)
}
fn home_dir() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"))
}
fn user_systemd_dir() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| home_dir().join(".config"))
.join("systemd/user")
}
fn is_system_prefix(prefix: &Path) -> bool {
match dirs::home_dir() {
Some(home) => !prefix.starts_with(&home),
None => true,
}
}
fn normalize_prefix(raw: &str) -> PathBuf {
normalize_prefix_path(&expand_tilde(raw))
}
fn normalize_prefix_path(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
}
}
fn expand_tilde(path: &str) -> PathBuf {
if path == "~" {
home_dir()
} else if let Some(rest) = path.strip_prefix("~/") {
home_dir().join(rest)
} else {
PathBuf::from(path)
}
}
fn is_permission_denied(err: &io::Error) -> bool {
err.kind() == io::ErrorKind::PermissionDenied
}
pub fn privilege_denied_msg(dest: &Path) -> String {
format!(
"permission denied writing {} — need root for this prefix. \
bakery tried `sudo -n` then `pkexec`; neither succeeded. \
Run from a root shell, grant passwordless sudo -n for install/rm/tar, \
or install a polkit rule. bakery will not prompt for a sudo password.",
dest.display()
)
}
fn has_graphical_session() -> bool {
std::env::var_os("WAYLAND_DISPLAY").is_some() || std::env::var_os("DISPLAY").is_some()
}
/// Write `bytes` to `dest`, creating parent dirs. Escalates on `EACCES`.
pub fn write_bytes(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
match bread_utils::atomic::write_atomic_bytes(dest, bytes, Some(mode)) {
Ok(()) => Ok(()),
Err(e) if is_permission_denied(&e) => write_bytes_privileged(dest, bytes, mode),
Err(e) => Err(e).with_context(|| format!("writing {}", dest.display())),
}
}
fn write_bytes_privileged(dest: &Path, bytes: &[u8], mode: u32) -> Result<()> {
let mut tmp =
tempfile::NamedTempFile::new().context("creating temp file for privileged write")?;
tmp.write_all(bytes)
.and_then(|_| tmp.flush())
.and_then(|_| tmp.as_file().sync_all())
.context("writing temp file for privileged write")?;
let mode_str = format!("{mode:o}");
run_privileged(
Path::new("/usr/bin/install"),
&[
OsStr::new("-D"),
OsStr::new("-m"),
OsStr::new(&mode_str),
tmp.path().as_os_str(),
dest.as_os_str(),
],
dest,
)
}
pub fn create_dir_all(path: &Path) -> Result<()> {
match std::fs::create_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if is_permission_denied(&e) => run_privileged(
Path::new("/usr/bin/install"),
&[
OsStr::new("-d"),
OsStr::new("-m"),
OsStr::new("755"),
path.as_os_str(),
],
path,
),
Err(e) => Err(e).with_context(|| format!("creating directory {}", path.display())),
}
}
pub fn remove_file(path: &Path) -> Result<()> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) if is_permission_denied(&e) => run_privileged(
Path::new("/usr/bin/rm"),
&[OsStr::new("-f"), path.as_os_str()],
path,
),
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
}
}
pub fn remove_dir_all(path: &Path) -> Result<()> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) if is_permission_denied(&e) => run_privileged(
Path::new("/usr/bin/rm"),
&[OsStr::new("-rf"), path.as_os_str()],
path,
),
Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
}
}
/// Extract `archive` (a `.tar.gz`) into `dest_dir`. Escalates the `tar`
/// invocation when `dest_dir` is not writable by this user — typical for
/// `$prefix/share/<pkg>` under `/usr/local`.
pub fn extract_tar_gz(archive: &Path, dest_dir: &Path) -> Result<()> {
create_dir_all(dest_dir)?;
if dir_writable_by_self(dest_dir) {
let status = Command::new("tar")
.args([
"xzf",
&archive.to_string_lossy(),
"--no-same-owner",
"--no-same-permissions",
"-C",
])
.arg(dest_dir)
.status()
.with_context(|| format!("running tar to extract {}", archive.display()))?;
if !status.success() {
bail!("tar exited with {status} extracting {}", archive.display());
}
return Ok(());
}
run_privileged(
Path::new("/usr/bin/tar"),
&[
OsStr::new("xzf"),
archive.as_os_str(),
OsStr::new("--no-same-owner"),
OsStr::new("--no-same-permissions"),
OsStr::new("-C"),
dest_dir.as_os_str(),
],
dest_dir,
)
}
fn dir_writable_by_self(dir: &Path) -> bool {
tempfile::Builder::new()
.prefix(".bakery-wprobe-")
.tempfile_in(dir)
.is_ok()
}
fn run_privileged(program: &Path, args: &[&OsStr], dest: &Path) -> Result<()> {
// `sudo -n` never prompts; stdin is null so a misconfigured sudoers
// can't fall through to a password read on a GUI hook's non-tty stdin.
let sudo = Command::new("sudo")
.arg("-n")
.arg(program)
.args(args)
.stdin(Stdio::null())
.status();
if matches!(sudo, Ok(status) if status.success()) {
return Ok(());
}
// pkexec pops a polkit dialog — only useful with a display, and the
// one acceptable password prompt (GUI, not a stolen sudo TTY).
if has_graphical_session() {
let pk = Command::new("pkexec").arg(program).args(args).status();
if matches!(pk, Ok(status) if status.success()) {
return Ok(());
}
}
bail!("{}", privilege_denied_msg(dest))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn user_default_is_not_system_and_uses_local_bin() {
let layout = Layout::user_default(None);
assert!(!layout.is_system);
assert_eq!(layout.prefix, default_user_prefix());
assert_eq!(layout.bin_dir, default_user_prefix().join("bin"));
assert_eq!(layout.kind_label(), "user");
assert!(layout.systemd_user_dir.ends_with(Path::new("systemd/user")));
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
}
#[test]
fn user_default_honors_bin_override() {
let layout = Layout::user_default(Some(PathBuf::from("/tmp/custom-bins")));
assert_eq!(layout.bin_dir, PathBuf::from("/tmp/custom-bins"));
assert!(!layout.is_system);
assert_eq!(layout.prefix, default_user_prefix());
}
#[test]
fn usr_local_is_system_layout() {
let layout = Layout::from_prefix(Path::new("/usr/local"), None);
assert!(layout.is_system);
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
assert_eq!(layout.bin_dir, PathBuf::from("/usr/local/bin"));
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
assert_eq!(layout.kind_label(), "system");
}
#[test]
fn custom_home_prefix_is_not_system() {
let home = dirs::home_dir().expect("home dir");
let prefix = home.join("apps");
let layout = Layout::from_prefix(&prefix, None);
assert!(!layout.is_system);
assert_eq!(layout.bin_dir, prefix.join("bin"));
assert_eq!(layout.share_dir, prefix.join("share"));
assert_ne!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
}
#[test]
fn temp_prefix_maps_bin_and_share_under_prefix() {
let dir = tempdir().unwrap();
let layout = Layout::from_prefix(dir.path(), None);
assert_eq!(layout.bin_dir, dir.path().join("bin"));
assert_eq!(layout.share_dir, dir.path().join("share"));
// /tmp is not under $HOME, so this is a system-shaped prefix —
// units would go to /usr/lib/systemd/user. Writes still try
// unprivileged first, so tests can use a temp prefix without sudo.
assert!(layout.is_system);
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
}
#[test]
fn bin_override_does_not_move_share_or_units() {
let layout = Layout::from_prefix(
Path::new("/usr/local"),
Some(PathBuf::from("/opt/override/bin")),
);
assert_eq!(layout.bin_dir, PathBuf::from("/opt/override/bin"));
assert_eq!(layout.share_dir, PathBuf::from("/usr/local/share"));
assert_eq!(layout.systemd_user_dir, PathBuf::from(SYSTEM_USER_UNIT_DIR));
}
#[test]
fn load_config_prefix_reads_value() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
assert_eq!(load_config_prefix(&path), Some(PathBuf::from("/usr/local")));
}
#[test]
fn load_config_prefix_expands_tilde() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"~/.local\"\n").unwrap();
assert_eq!(load_config_prefix(&path), Some(default_user_prefix()));
}
#[test]
fn load_config_prefix_missing_file_is_none() {
assert_eq!(
load_config_prefix(Path::new("/no/such/bakery-config.toml")),
None
);
}
#[test]
fn load_config_prefix_ignores_empty_value() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"\"\n").unwrap();
assert_eq!(load_config_prefix(&path), None);
}
#[test]
fn load_config_prefix_malformed_is_none() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = [\n").unwrap();
assert_eq!(load_config_prefix(&path), None);
}
#[test]
fn env_prefix_wins_over_config() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
let layout = resolve_from(Some("/opt/bread"), &path, None);
assert_eq!(layout.prefix, PathBuf::from("/opt/bread"));
assert_eq!(layout.bin_dir, PathBuf::from("/opt/bread/bin"));
assert!(layout.is_system);
}
#[test]
fn empty_env_falls_through_to_config() {
let dir = tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "prefix = \"/usr/local\"\n").unwrap();
let layout = resolve_from(Some(" "), &path, None);
assert_eq!(layout.prefix, PathBuf::from("/usr/local"));
}
#[test]
fn no_env_no_config_is_user_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("missing.toml");
let layout = resolve_from(None, &path, None);
assert_eq!(layout, Layout::user_default(None));
}
#[test]
fn write_bytes_to_writable_temp_prefix_needs_no_root() {
let dir = tempdir().unwrap();
let dest = dir.path().join("bin").join("foo");
write_bytes(&dest, b"hello", 0o755).unwrap();
assert_eq!(fs::read(&dest).unwrap(), b"hello");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&dest).unwrap().permissions().mode() & 0o777,
0o755
);
}
}
#[test]
fn create_and_remove_under_temp_prefix() {
let dir = tempdir().unwrap();
let nested = dir.path().join("share/licenses/pkg");
create_dir_all(&nested).unwrap();
assert!(nested.is_dir());
let file = nested.join("LICENSE");
write_bytes(&file, b"MIT\n", 0o644).unwrap();
remove_file(&file).unwrap();
assert!(!file.exists());
remove_dir_all(&dir.path().join("share")).unwrap();
assert!(!dir.path().join("share").exists());
}
#[test]
fn privilege_denied_msg_names_the_dest() {
let msg = privilege_denied_msg(Path::new("/usr/local/bin/breadd"));
assert!(msg.contains("/usr/local/bin/breadd"));
assert!(msg.contains("sudo -n"));
assert!(msg.contains("pkexec"));
assert!(msg.contains("will not prompt"));
}
#[test]
fn is_system_prefix_classifies_home_and_usr() {
let home = dirs::home_dir().expect("home dir");
assert!(!is_system_prefix(&home.join(".local")));
assert!(is_system_prefix(Path::new("/usr/local")));
assert!(is_system_prefix(Path::new("/opt/bread")));
}
}

View file

@ -56,8 +56,7 @@ impl State {
pub fn save(&self) -> Result<()> {
let path = state_path();
let text = serde_json::to_string_pretty(self)?;
bread_utils::atomic::write_atomic(&path, &text, None)
.context("writing installed.json")
bread_utils::atomic::write_atomic(&path, &text, None).context("writing installed.json")
}
/// Runs `f` against a freshly-loaded `State` while holding an exclusive
@ -114,7 +113,13 @@ fn state_base_dir() -> PathBuf {
}
fn state_path() -> PathBuf {
state_base_dir().join("bakery/installed.json")
bakery_state_dir().join("installed.json")
}
/// Per-user bakery state dir (`~/.local/state/bakery`). Independent of the
/// install prefix — system-prefix installs still record what this user asked for.
pub fn bakery_state_dir() -> PathBuf {
state_base_dir().join("bakery")
}
/// Local backup dir for `pkg_name`'s `version` binaries, populated by
@ -205,7 +210,10 @@ mod tests {
assert_eq!(restored.packages["bar"].version, "2.0.0");
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
assert_eq!(restored.packages["bar"].track, Track::Beta);
assert_eq!(restored.packages["bar"].previous_version.as_deref(), Some("1.0.0"));
assert_eq!(
restored.packages["bar"].previous_version.as_deref(),
Some("1.0.0")
);
assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123");
}
@ -228,6 +236,14 @@ mod tests {
assert!(installed.binary_sha256.is_empty());
}
#[test]
fn bakery_state_dir_is_under_state_home_and_independent_of_prefix() {
let dir = bakery_state_dir();
assert!(dir.ends_with("bakery"));
// Must not follow BAKERY_PREFIX — state is always per-user.
assert!(!dir.starts_with("/usr/local"));
}
#[test]
fn backup_dir_is_distinct_per_package_and_version() {
let a = backup_dir("bakery", "0.3.1");