From c296d26408d90f956ef04841db4c16bfbd616062 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 16 Aug 2026 00:00:19 +0800 Subject: [PATCH] bakery: add system prefix installs for BOS 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. --- AGENTS.md | 3 + Cargo.lock | 1 + README.md | 20 ++ bakery/Cargo.toml | 1 + bakery/README.md | 30 +++ bakery/src/download.rs | 2 +- bakery/src/install.rs | 142 ++++++----- bakery/src/main.rs | 77 +++--- bakery/src/manifest.rs | 10 +- bakery/src/prefix.rs | 546 +++++++++++++++++++++++++++++++++++++++++ bakery/src/state.rs | 24 +- 11 files changed, 738 insertions(+), 118 deletions(-) create mode 100644 bakery/README.md create mode 100644 bakery/src/prefix.rs diff --git a/AGENTS.md b/AGENTS.md index d338d07..c8819c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/Cargo.lock b/Cargo.lock index 66eb7fc..235b235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "toml 0.8.23", "ureq", ] diff --git a/README.md b/README.md index d3ed536..b769262 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,26 @@ bakery remove # 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. diff --git a/bakery/Cargo.toml b/bakery/Cargo.toml index 1ccd0fe..06ca297 100644 --- a/bakery/Cargo.toml +++ b/bakery/Cargo.toml @@ -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 } diff --git a/bakery/README.md b/bakery/README.md new file mode 100644 index 0000000..21cb2cd --- /dev/null +++ b/bakery/README.md @@ -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. diff --git a/bakery/src/download.rs b/bakery/src/download.rs index d59bf67..4d1c1c7 100644 --- a/bakery/src/download.rs +++ b/bakery/src/download.rs @@ -20,7 +20,7 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result { 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()) diff --git a/bakery/src/install.rs b/bakery/src/install.rs index 19a7c76..2040e35 100644 --- a/bakery/src/install.rs +++ b/bakery/src/install.rs @@ -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/` destination) so tests can extract into +/// the real `$prefix/share/` 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 { - eprintln!( - " {}", - ui::warn(&format!("tar exited with {status} extracting {filename}")) - ); + ), + Err(e) => { + eprintln!( + " {}", + 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> { 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::>() .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 { 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")); } diff --git a/bakery/src/main.rs b/bakery/src/main.rs index d3d09b4..11659a4 100644 --- a/bakery/src/main.rs +++ b/bakery/src/main.rs @@ -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//) — config is still preserved + /// ($prefix/share//) — 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, diff --git a/bakery/src/manifest.rs b/bakery/src/manifest.rs index 5cf4e9c..0ce04d7 100644 --- a/bakery/src/manifest.rs +++ b/bakery/src/manifest.rs @@ -107,21 +107,21 @@ pub struct Package { #[serde(default)] pub post_install: Vec, /// License artifact filename (e.g. "LICENSE"), installed to - /// `~/.local/share/licenses//LICENSE` — the bakery equivalent of - /// what a PKGBUILD's `package()` does with `/usr/share/licenses`. + /// `$prefix/share/licenses//LICENSE` (`~/.local/share/...` by + /// default) — the bakery equivalent of a PKGBUILD's `package()` step. #[serde(default)] pub license_file: Option, #[serde(default)] pub license_file_sha256: Option, /// Desktop entry artifact filename (e.g. "breadhelp.desktop"), - /// installed to `~/.local/share/applications/.desktop` so the - /// app shows up in any XDG-compliant launcher without root. + /// installed to `$prefix/share/applications/.desktop` so the + /// app shows up in any XDG-compliant launcher. #[serde(default)] pub desktop_file: Option, #[serde(default)] pub desktop_file_sha256: Option, /// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz` - /// in the release dir, extracted to `~/.local/share//` on + /// in the release dir, extracted to `$prefix/share//` 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 diff --git a/bakery/src/prefix.rs b/bakery/src/prefix.rs new file mode 100644 index 0000000..dd5b8bd --- /dev/null +++ b/bakery/src/prefix.rs @@ -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) -> 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) -> 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) -> 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, +) -> 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 { + 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, +} + +/// 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 { + 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::(&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/` 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"))); + } +} diff --git a/bakery/src/state.rs b/bakery/src/state.rs index 4e29055..bee3f9c 100644 --- a/bakery/src/state.rs +++ b/bakery/src/state.rs @@ -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");