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
|
|
@ -19,3 +19,6 @@ Follow [`CONTRIBUTING.md`](CONTRIBUTING.md) for any git, branch, or release work
|
||||||
|
|
||||||
## Don't
|
## Don't
|
||||||
- Don't embed credentials in remote URLs — SSH or a credential helper only.
|
- 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
1
Cargo.lock
generated
|
|
@ -120,6 +120,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"toml 0.8.23",
|
||||||
"ureq",
|
"ureq",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -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`).
|
`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
|
## System dependencies by product
|
||||||
|
|
||||||
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
|
`bakery doctor` checks these automatically before any install. Required deps block installation; optional deps generate a warning but never block.
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ repository = "https://git.breadway.dev/Breadway/bread-ecosystem"
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
toml = { workspace = true }
|
||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
ureq = { workspace = true }
|
ureq = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
|
|
|
||||||
30
bakery/README.md
Normal file
30
bakery/README.md
Normal 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.
|
||||||
|
|
@ -20,7 +20,7 @@ pub fn fetch_and_place(binary: &Binary, dest: &Path) -> Result<String> {
|
||||||
verify_sha256(&bytes, &binary.sha256)
|
verify_sha256(&bytes, &binary.sha256)
|
||||||
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
|
.with_context(|| format!("checksum mismatch for {}", binary.name))?;
|
||||||
|
|
||||||
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()))?;
|
.with_context(|| format!("placing binary at {}", dest.display()))?;
|
||||||
ui::step("placed", &dest.display().to_string());
|
ui::step("placed", &dest.display().to_string());
|
||||||
Ok(binary.sha256.clone())
|
Ok(binary.sha256.clone())
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use std::process::Command;
|
||||||
|
|
||||||
use crate::download::{fetch_and_place, verify_sha256};
|
use crate::download::{fetch_and_place, verify_sha256};
|
||||||
use crate::manifest::{fetch_binary, Package, Service};
|
use crate::manifest::{fetch_binary, Package, Service};
|
||||||
|
use crate::prefix::{self, Layout};
|
||||||
use crate::state::{InstalledPackage, State};
|
use crate::state::{InstalledPackage, State};
|
||||||
use crate::track::Track;
|
use crate::track::Track;
|
||||||
use crate::ui;
|
use crate::ui;
|
||||||
|
|
@ -68,7 +69,7 @@ fn confirm(prompt: &str, assume_yes: bool) -> bool {
|
||||||
/// install" for the pre-overwrite backup below.
|
/// install" for the pre-overwrite backup below.
|
||||||
pub fn install_package(
|
pub fn install_package(
|
||||||
pkg: &Package,
|
pkg: &Package,
|
||||||
bin_dir: &Path,
|
layout: &Layout,
|
||||||
track: Track,
|
track: Track,
|
||||||
previous: Option<&InstalledPackage>,
|
previous: Option<&InstalledPackage>,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
|
|
@ -78,14 +79,17 @@ pub fn install_package(
|
||||||
|
|
||||||
// 1. Download and verify all binaries. On an update (not a fresh
|
// 1. Download and verify all binaries. On an update (not a fresh
|
||||||
// install), back up the current binary first — best-effort, feeding
|
// 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 backup_dir = previous.map(|prev| crate::state::backup_dir(&pkg.name, &prev.version));
|
||||||
let mut binary_names = Vec::new();
|
let mut binary_names = Vec::new();
|
||||||
let mut binary_sha256 = HashMap::new();
|
let mut binary_sha256 = HashMap::new();
|
||||||
for bin in &pkg.binaries {
|
for bin in &pkg.binaries {
|
||||||
ensure_safe_component(&bin.name, "binary name")?;
|
ensure_safe_component(&bin.name, "binary name")?;
|
||||||
let install_name = strip_arch_suffix(&bin.name);
|
let install_name = strip_arch_suffix(&bin.name);
|
||||||
let dest = bin_dir.join(install_name);
|
let dest = layout.bin_dir.join(install_name);
|
||||||
if let Some(dir) = &backup_dir {
|
if let Some(dir) = &backup_dir {
|
||||||
backup_current_binary(dir, install_name, &dest);
|
backup_current_binary(dir, install_name, &dest);
|
||||||
}
|
}
|
||||||
|
|
@ -94,30 +98,32 @@ pub fn install_package(
|
||||||
binary_sha256.insert(install_name.to_string(), sha256);
|
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 {
|
if let Some(cfg) = &pkg.config {
|
||||||
scaffold_config(cfg, pkg)?;
|
scaffold_config(cfg, pkg)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Install license file, if declared.
|
// 3. Install license file, if declared.
|
||||||
if let Some(license) = &pkg.license_file {
|
if let Some(license) = &pkg.license_file {
|
||||||
install_license(pkg, license)?;
|
install_license(pkg, license, layout)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Install desktop entry, if declared.
|
// 4. Install desktop entry, if declared.
|
||||||
if let Some(desktop) = &pkg.desktop_file {
|
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.
|
// 5. Download + extract data archive, if declared.
|
||||||
if let Some(archive) = &pkg.data_archive {
|
if let Some(archive) = &pkg.data_archive {
|
||||||
install_data_archive(pkg, archive)?;
|
install_data_archive(pkg, archive, layout)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Install systemd user units.
|
// 6. Install systemd user units.
|
||||||
let mut service_names = Vec::new();
|
let mut service_names = Vec::new();
|
||||||
for svc in &pkg.services {
|
for svc in &pkg.services {
|
||||||
install_service(svc, bin_dir, pkg)?;
|
install_service(svc, layout, pkg)?;
|
||||||
service_names.push(svc.unit.clone());
|
service_names.push(svc.unit.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,7 +179,7 @@ pub fn install_package(
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
println!(" {}", ui::ok(&format!("{} installed", pkg.name)));
|
println!(" {}", ui::ok(&format!("{} installed", pkg.name)));
|
||||||
warn_path_if_needed(bin_dir);
|
warn_path_if_needed(&layout.bin_dir);
|
||||||
Ok(())
|
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 = State::with_lock(|state| Ok(state.remove(pkg_name)))?;
|
||||||
let installed = match installed {
|
let installed = match installed {
|
||||||
Some(p) => p,
|
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.
|
// the config/data-preserved messages below.
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
for bin in &installed.binaries {
|
for bin in &installed.binaries {
|
||||||
let path = bin_dir.join(bin);
|
let path = layout.bin_dir.join(bin);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
match std::fs::remove_file(&path) {
|
match prefix::remove_file(&path) {
|
||||||
Ok(()) => ui::step("removed", &path.display().to_string()),
|
Ok(()) => ui::step("removed", &path.display().to_string()),
|
||||||
Err(e) => failures.push(format!("{}: {e}", path.display())),
|
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.
|
// Prompt for unit removal.
|
||||||
if !installed.services.is_empty() {
|
if !installed.services.is_empty() {
|
||||||
let service_dir = systemd_user_dir();
|
let service_dir = &layout.systemd_user_dir;
|
||||||
for unit in &installed.services {
|
for unit in &installed.services {
|
||||||
let unit_path = service_dir.join(unit);
|
let unit_path = service_dir.join(unit);
|
||||||
if confirm_remove_unit(unit, assume_yes) {
|
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])
|
.args(["--user", "disable", "--now", unit])
|
||||||
.status();
|
.status();
|
||||||
if unit_path.exists() {
|
if unit_path.exists() {
|
||||||
std::fs::remove_file(&unit_path).ok();
|
let _ = prefix::remove_file(&unit_path);
|
||||||
}
|
}
|
||||||
let _ = Command::new("systemctl")
|
let _ = Command::new("systemctl")
|
||||||
.args(["--user", "daemon-reload"])
|
.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);
|
let data_dir = share_dir.join(pkg_name);
|
||||||
|
|
||||||
if purge {
|
if purge {
|
||||||
|
|
@ -331,9 +342,9 @@ fn remove_purged_path(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let result = if recursive {
|
let result = if recursive {
|
||||||
std::fs::remove_dir_all(path)
|
prefix::remove_dir_all(path)
|
||||||
} else {
|
} else {
|
||||||
std::fs::remove_file(path)
|
prefix::remove_file(path)
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => ui::step("removed", &path.display().to_string()),
|
Ok(()) => ui::step("removed", &path.display().to_string()),
|
||||||
|
|
@ -451,28 +462,26 @@ fn fetch_verify_write(
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if let Some(parent) = dest.parent() {
|
prefix::write_bytes(dest, &bytes, 0o644)
|
||||||
std::fs::create_dir_all(parent)?;
|
.with_context(|| format!("writing {}", dest.display()))?;
|
||||||
}
|
|
||||||
std::fs::write(dest, &bytes).with_context(|| format!("writing {}", dest.display()))?;
|
|
||||||
ui::step("installed", &format!("{label} {}", dest.display()));
|
ui::step("installed", &format!("{label} {}", dest.display()));
|
||||||
Ok(())
|
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")?;
|
ensure_safe_component(filename, "license_file")?;
|
||||||
let dest = dirs::data_dir()
|
let dest = layout
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
.share_dir
|
||||||
.join("licenses")
|
.join("licenses")
|
||||||
.join(&pkg.name)
|
.join(&pkg.name)
|
||||||
.join("LICENSE");
|
.join("LICENSE");
|
||||||
fetch_verify_write(pkg, filename, &pkg.license_file_sha256, &dest, "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")?;
|
ensure_safe_component(filename, "desktop_file")?;
|
||||||
let dest = dirs::data_dir()
|
let dest = layout
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
.share_dir
|
||||||
.join("applications")
|
.join("applications")
|
||||||
.join(format!("{}.desktop", pkg.name));
|
.join(format!("{}.desktop", pkg.name));
|
||||||
fetch_verify_write(
|
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")?;
|
ensure_safe_component(filename, "data_archive")?;
|
||||||
let data_dir = dirs::data_dir()
|
let data_dir = layout.share_dir.join(&pkg.name);
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
|
||||||
.join(&pkg.name);
|
|
||||||
fetch_extract_archive(pkg, filename, &pkg.data_archive_sha256, &data_dir)
|
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
|
/// crate dependency — `tar` is universally present on Linux and this file
|
||||||
/// already shells out to `systemctl` for the same "trust the base system
|
/// already shells out to `systemctl` for the same "trust the base system
|
||||||
/// has this" reason. Split from `install_data_archive` (which just supplies
|
/// 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.
|
/// a tempdir instead.
|
||||||
fn fetch_extract_archive(
|
fn fetch_extract_archive(
|
||||||
pkg: &Package,
|
pkg: &Package,
|
||||||
|
|
@ -523,31 +530,19 @@ fn fetch_extract_archive(
|
||||||
|
|
||||||
verify_archive_paths(&tmp_archive)?;
|
verify_archive_paths(&tmp_archive)?;
|
||||||
|
|
||||||
std::fs::create_dir_all(dest_dir)?;
|
match prefix::extract_tar_gz(&tmp_archive, dest_dir) {
|
||||||
let status = Command::new("tar")
|
Ok(()) => ui::step(
|
||||||
.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(
|
|
||||||
"extracted",
|
"extracted",
|
||||||
&format!("{filename} → {}", dest_dir.display()),
|
&format!("{filename} → {}", dest_dir.display()),
|
||||||
);
|
),
|
||||||
} else {
|
Err(e) => {
|
||||||
eprintln!(
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -609,11 +604,11 @@ fn fetch_and_verify_unit(pkg: &Package, svc: &Service) -> Result<Vec<u8>> {
|
||||||
Ok(bytes)
|
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")?;
|
ensure_safe_component(&svc.unit, "service unit")?;
|
||||||
|
|
||||||
let service_dir = systemd_user_dir();
|
let service_dir = &layout.systemd_user_dir;
|
||||||
std::fs::create_dir_all(&service_dir)?;
|
prefix::create_dir_all(service_dir)?;
|
||||||
|
|
||||||
let unit_path = service_dir.join(&svc.unit);
|
let unit_path = service_dir.join(&svc.unit);
|
||||||
let had_existing = unit_path.exists();
|
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
|
// applied after the first install, unlike binaries (which always
|
||||||
// re-fetch via `fetch_and_place` on every install/update). If the fetch
|
// 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
|
// 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) {
|
match fetch_and_verify_unit(pkg, svc) {
|
||||||
Ok(bytes) => {
|
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()))?;
|
.with_context(|| format!("writing {}", unit_path.display()))?;
|
||||||
ui::step("unit", &unit_path.display().to_string());
|
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
|
svc.unit
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
patch_exec_start(&unit_path, &layout.bin_dir)?;
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
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")
|
if !Command::new("systemctl")
|
||||||
.args(["--user", "daemon-reload"])
|
.args(["--user", "daemon-reload"])
|
||||||
.status()
|
.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<()> {
|
fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||||
let text = std::fs::read_to_string(unit_path)?;
|
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
|
let patched: String = text
|
||||||
.lines()
|
.lines()
|
||||||
.map(|line| {
|
.map(|line| {
|
||||||
|
|
@ -721,14 +725,11 @@ fn patch_exec_start(unit_path: &Path, bin_dir: &Path) -> Result<()> {
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("\n");
|
.join("\n");
|
||||||
// Preserve trailing newline if the original had one.
|
if text.ends_with('\n') {
|
||||||
let output = if text.ends_with('\n') {
|
|
||||||
format!("{patched}\n")
|
format!("{patched}\n")
|
||||||
} else {
|
} else {
|
||||||
patched
|
patched
|
||||||
};
|
}
|
||||||
std::fs::write(unit_path, output)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_hook(cmd: &str, pkg_name: &str) -> Result<()> {
|
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)
|
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> {
|
fn guess_config_dir(pkg_name: &str) -> Option<PathBuf> {
|
||||||
Some(dirs::config_dir()?.join(pkg_name))
|
Some(dirs::config_dir()?.join(pkg_name))
|
||||||
}
|
}
|
||||||
|
|
@ -1063,7 +1058,8 @@ mod tests {
|
||||||
let mut pkg = test_package(&base_url);
|
let mut pkg = test_package(&base_url);
|
||||||
pkg.name = "../evil".to_string();
|
pkg.name = "../evil".to_string();
|
||||||
let dir = tempdir().unwrap();
|
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"));
|
assert!(err.to_string().contains("not a safe filename"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ mod doctor;
|
||||||
mod download;
|
mod download;
|
||||||
mod install;
|
mod install;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
|
mod prefix;
|
||||||
mod state;
|
mod state;
|
||||||
mod track;
|
mod track;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
@ -48,7 +49,7 @@ enum Cmd {
|
||||||
Remove {
|
Remove {
|
||||||
package: String,
|
package: String,
|
||||||
/// Also remove the license file, desktop entry, and data dir
|
/// 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)]
|
#[arg(long)]
|
||||||
purge: bool,
|
purge: bool,
|
||||||
},
|
},
|
||||||
|
|
@ -104,15 +105,9 @@ enum TrackCmd {
|
||||||
Set { track: Track },
|
Set { track: Track },
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_bin_dir() -> PathBuf {
|
|
||||||
dirs::home_dir()
|
|
||||||
.unwrap_or_else(|| PathBuf::from("~"))
|
|
||||||
.join(".local/bin")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let bin_dir = cli.bin_dir.unwrap_or_else(default_bin_dir);
|
let layout = prefix::resolve(cli.bin_dir);
|
||||||
let no_hooks = cli.no_hooks;
|
let no_hooks = cli.no_hooks;
|
||||||
let assume_yes = cli.yes;
|
let assume_yes = cli.yes;
|
||||||
let dry_run = cli.dry_run;
|
let dry_run = cli.dry_run;
|
||||||
|
|
@ -122,15 +117,15 @@ fn main() -> Result<()> {
|
||||||
Cmd::Install { packages } => {
|
Cmd::Install { packages } => {
|
||||||
let index = manifest::load(true, track)?;
|
let index = manifest::load(true, track)?;
|
||||||
for pkg in &packages {
|
for pkg in &packages {
|
||||||
cmd_install(&index, pkg, &bin_dir, track, no_hooks, assume_yes, dry_run)?;
|
cmd_install(&index, pkg, &layout, track, no_hooks, assume_yes, dry_run)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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(
|
Cmd::Update { package, all } => cmd_update(
|
||||||
package.as_deref(),
|
package.as_deref(),
|
||||||
all,
|
all,
|
||||||
&bin_dir,
|
&layout,
|
||||||
track,
|
track,
|
||||||
no_hooks,
|
no_hooks,
|
||||||
assume_yes,
|
assume_yes,
|
||||||
|
|
@ -139,9 +134,9 @@ fn main() -> Result<()> {
|
||||||
Cmd::List { installed } => cmd_list(installed, track),
|
Cmd::List { installed } => cmd_list(installed, track),
|
||||||
Cmd::Info { package } => cmd_info(&package, track),
|
Cmd::Info { package } => cmd_info(&package, track),
|
||||||
Cmd::Search { query } => cmd_search(&query, track),
|
Cmd::Search { query } => cmd_search(&query, track),
|
||||||
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &bin_dir),
|
Cmd::Doctor { package } => cmd_doctor(package.as_deref(), track, &layout),
|
||||||
Cmd::Verify { package } => cmd_verify(package.as_deref(), &bin_dir),
|
Cmd::Verify { package } => cmd_verify(package.as_deref(), &layout.bin_dir),
|
||||||
Cmd::Rollback { package } => cmd_rollback(&package, &bin_dir),
|
Cmd::Rollback { package } => cmd_rollback(&package, &layout.bin_dir),
|
||||||
// Same update logic as `bakery update bakery` — this is just a
|
// Same update logic as `bakery update bakery` — this is just a
|
||||||
// documented, discoverable entry point for it, since overwriting
|
// documented, discoverable entry point for it, since overwriting
|
||||||
// bakery's own running binary via a normal update already works
|
// bakery's own running binary via a normal update already works
|
||||||
|
|
@ -150,7 +145,7 @@ fn main() -> Result<()> {
|
||||||
Cmd::SelfUpdate => cmd_update(
|
Cmd::SelfUpdate => cmd_update(
|
||||||
Some("bakery"),
|
Some("bakery"),
|
||||||
false,
|
false,
|
||||||
&bin_dir,
|
&layout,
|
||||||
track,
|
track,
|
||||||
no_hooks,
|
no_hooks,
|
||||||
assume_yes,
|
assume_yes,
|
||||||
|
|
@ -199,7 +194,7 @@ fn cmd_track(action: TrackCmd) -> Result<()> {
|
||||||
fn cmd_install(
|
fn cmd_install(
|
||||||
index: &manifest::Index,
|
index: &manifest::Index,
|
||||||
name: &str,
|
name: &str,
|
||||||
bin_dir: &std::path::Path,
|
layout: &prefix::Layout,
|
||||||
track: Track,
|
track: Track,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
|
|
@ -209,7 +204,7 @@ fn cmd_install(
|
||||||
install_with_deps(
|
install_with_deps(
|
||||||
index,
|
index,
|
||||||
name,
|
name,
|
||||||
bin_dir,
|
layout,
|
||||||
track,
|
track,
|
||||||
no_hooks,
|
no_hooks,
|
||||||
assume_yes,
|
assume_yes,
|
||||||
|
|
@ -224,7 +219,7 @@ fn cmd_install(
|
||||||
fn install_with_deps(
|
fn install_with_deps(
|
||||||
index: &manifest::Index,
|
index: &manifest::Index,
|
||||||
name: &str,
|
name: &str,
|
||||||
bin_dir: &std::path::Path,
|
layout: &prefix::Layout,
|
||||||
track: Track,
|
track: Track,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
|
|
@ -245,7 +240,7 @@ fn install_with_deps(
|
||||||
if !state.is_installed(&dep) {
|
if !state.is_installed(&dep) {
|
||||||
ui::step(if dry_run { "would need" } else { "dependency" }, &dep);
|
ui::step(if dry_run { "would need" } else { "dependency" }, &dep);
|
||||||
install_with_deps(
|
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(());
|
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
|
/// 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<()> {
|
fn cmd_remove(name: &str, layout: &prefix::Layout, assume_yes: bool, purge: bool) -> Result<()> {
|
||||||
install::remove_package(name, bin_dir, assume_yes, purge)
|
install::remove_package(name, layout, assume_yes, purge)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn cmd_update(
|
fn cmd_update(
|
||||||
name: Option<&str>,
|
name: Option<&str>,
|
||||||
all: bool,
|
all: bool,
|
||||||
bin_dir: &std::path::Path,
|
layout: &prefix::Layout,
|
||||||
track: Track,
|
track: Track,
|
||||||
no_hooks: bool,
|
no_hooks: bool,
|
||||||
assume_yes: bool,
|
assume_yes: bool,
|
||||||
|
|
@ -486,14 +481,9 @@ fn cmd_update(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Err(e) = install::install_package(
|
if let Err(e) =
|
||||||
latest,
|
install::install_package(latest, layout, track, Some(installed), no_hooks, assume_yes)
|
||||||
bin_dir,
|
{
|
||||||
track,
|
|
||||||
Some(installed),
|
|
||||||
no_hooks,
|
|
||||||
assume_yes,
|
|
||||||
) {
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" {}",
|
||||||
ui::fail(&format!("failed to update {pkg_name}: {e}"))
|
ui::fail(&format!("failed to update {pkg_name}: {e}"))
|
||||||
|
|
@ -705,7 +695,18 @@ fn cmd_info(name: &str, track: Track) -> Result<()> {
|
||||||
Ok(())
|
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 index = manifest::load(false, track)?;
|
||||||
let state = state::State::load()?;
|
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() {
|
if targets.is_empty() {
|
||||||
|
ui::heading("Doctor", &["no packages"]);
|
||||||
|
report_layout(layout);
|
||||||
|
println!();
|
||||||
println!("{}", ui::dim("no packages installed — nothing to check"));
|
println!("{}", ui::dim("no packages installed — nothing to check"));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -727,6 +731,8 @@ fn cmd_doctor(name: Option<&str>, track: Track, bin_dir: &std::path::Path) -> Re
|
||||||
let mut targets = targets;
|
let mut targets = targets;
|
||||||
targets.sort();
|
targets.sort();
|
||||||
ui::heading("Doctor", &[&format!("{} packages", targets.len())]);
|
ui::heading("Doctor", &[&format!("{} packages", targets.len())]);
|
||||||
|
report_layout(layout);
|
||||||
|
println!();
|
||||||
let name_w = ui::name_width(&targets);
|
let name_w = ui::name_width(&targets);
|
||||||
|
|
||||||
let mut all_ok = true;
|
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.
|
// only, not a checksum re-verification — see `bakery verify` for that.
|
||||||
if let Some(installed) = state.packages.get(pkg_name) {
|
if let Some(installed) = state.packages.get(pkg_name) {
|
||||||
for bin in &installed.binaries {
|
for bin in &installed.binaries {
|
||||||
let path = bin_dir.join(bin);
|
let path = layout.bin_dir.join(bin);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" {}",
|
" {}",
|
||||||
|
|
@ -906,7 +912,7 @@ fn restore_binaries(
|
||||||
.with_context(|| format!("reading backup {}", backup_path.display()))?;
|
.with_context(|| format!("reading backup {}", backup_path.display()))?;
|
||||||
let hash = hex::encode(Sha256::digest(&bytes));
|
let hash = hex::encode(Sha256::digest(&bytes));
|
||||||
let dest = bin_dir.join(bin);
|
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()))?;
|
.with_context(|| format!("restoring {}", dest.display()))?;
|
||||||
sha256.insert(bin.clone(), hash);
|
sha256.insert(bin.clone(), hash);
|
||||||
}
|
}
|
||||||
|
|
@ -1095,11 +1101,12 @@ mod tests {
|
||||||
};
|
};
|
||||||
|
|
||||||
let bin_dir = tempdir().unwrap();
|
let bin_dir = tempdir().unwrap();
|
||||||
|
let layout = prefix::Layout::from_prefix(bin_dir.path(), None);
|
||||||
let mut visited = HashSet::new();
|
let mut visited = HashSet::new();
|
||||||
install_with_deps(
|
install_with_deps(
|
||||||
&index,
|
&index,
|
||||||
name,
|
name,
|
||||||
bin_dir.path(),
|
&layout,
|
||||||
Track::Stable,
|
Track::Stable,
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
|
|
|
||||||
|
|
@ -107,21 +107,21 @@ pub struct Package {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub post_install: Vec<String>,
|
pub post_install: Vec<String>,
|
||||||
/// License artifact filename (e.g. "LICENSE"), installed to
|
/// License artifact filename (e.g. "LICENSE"), installed to
|
||||||
/// `~/.local/share/licenses/<name>/LICENSE` — the bakery equivalent of
|
/// `$prefix/share/licenses/<name>/LICENSE` (`~/.local/share/...` by
|
||||||
/// what a PKGBUILD's `package()` does with `/usr/share/licenses`.
|
/// default) — the bakery equivalent of a PKGBUILD's `package()` step.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub license_file: Option<String>,
|
pub license_file: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub license_file_sha256: Option<String>,
|
pub license_file_sha256: Option<String>,
|
||||||
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
|
/// Desktop entry artifact filename (e.g. "breadhelp.desktop"),
|
||||||
/// installed to `~/.local/share/applications/<name>.desktop` so the
|
/// installed to `$prefix/share/applications/<name>.desktop` so the
|
||||||
/// app shows up in any XDG-compliant launcher without root.
|
/// app shows up in any XDG-compliant launcher.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub desktop_file: Option<String>,
|
pub desktop_file: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub desktop_file_sha256: Option<String>,
|
pub desktop_file_sha256: Option<String>,
|
||||||
/// Data archive artifact filename (e.g. "content.tar.gz") — a `.tar.gz`
|
/// 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
|
/// install. For arbitrary data a package needs at runtime beyond a
|
||||||
/// config example (e.g. breadhelp's guide content), where a single
|
/// config example (e.g. breadhelp's guide content), where a single
|
||||||
/// downloadable file + `tar` extraction is simpler than teaching
|
/// 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<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
let path = state_path();
|
let path = state_path();
|
||||||
let text = serde_json::to_string_pretty(self)?;
|
let text = serde_json::to_string_pretty(self)?;
|
||||||
bread_utils::atomic::write_atomic(&path, &text, None)
|
bread_utils::atomic::write_atomic(&path, &text, None).context("writing installed.json")
|
||||||
.context("writing installed.json")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `f` against a freshly-loaded `State` while holding an exclusive
|
/// Runs `f` against a freshly-loaded `State` while holding an exclusive
|
||||||
|
|
@ -114,7 +113,13 @@ fn state_base_dir() -> PathBuf {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_path() -> 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
|
/// 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"].version, "2.0.0");
|
||||||
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
|
assert_eq!(restored.packages["bar"].services, ["bar.service"]);
|
||||||
assert_eq!(restored.packages["bar"].track, Track::Beta);
|
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");
|
assert_eq!(restored.packages["bar"].binary_sha256["bar"], "abc123");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +236,14 @@ mod tests {
|
||||||
assert!(installed.binary_sha256.is_empty());
|
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]
|
#[test]
|
||||||
fn backup_dir_is_distinct_per_package_and_version() {
|
fn backup_dir_is_distinct_per_package_and_version() {
|
||||||
let a = backup_dir("bakery", "0.3.1");
|
let a = backup_dir("bakery", "0.3.1");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue