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.
This commit is contained in:
parent
b20e4bcec1
commit
c296d26408
11 changed files with 738 additions and 118 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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<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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
546
bakery/src/prefix.rs
Normal 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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue