bread-ecosystem/bakery/src/state.rs
Breadway c296d26408
All checks were successful
dev bread-theme / build (push) Successful in 20s
dev bakery / build (push) Successful in 51s
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.
2026-08-16 00:00:19 +08:00

275 lines
9.8 KiB
Rust

use crate::track::Track;
use anyhow::{Context, Result};
use fs4::FileExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InstalledPackage {
pub name: String,
pub version: String,
pub binaries: Vec<String>,
pub services: Vec<String>,
pub installed_at: String,
// `#[serde(default)]` so an installed.json written before per-package
// track tracking existed still deserializes — defaults to Stable, same
// convention as `State.track` above.
#[serde(default)]
pub track: Track,
/// The version this package was upgraded from, if any — `bakery
/// rollback` uses this to find the matching local backup dir. `None` on
/// a fresh first-time install. `#[serde(default)]` for the same
/// old-shape-json reason as `track` above.
#[serde(default)]
pub previous_version: Option<String>,
/// SHA-256 (hex) of each installed binary, captured at install time.
/// `bakery verify` recomputes these from disk and compares against this
/// recorded value rather than a fresh index lookup — the index only
/// carries the checksum for whatever the *current latest* release is,
/// which may not match what's actually installed. Empty on installs
/// that predate this field.
#[serde(default)]
pub binary_sha256: HashMap<String, String>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
pub struct State {
// `#[serde(default)]` lets an installed.json written by a pre-track
// bakery binary deserialize straight into Track::Stable with no
// migration step.
#[serde(default)]
pub track: Track,
pub packages: HashMap<String, InstalledPackage>,
}
impl State {
pub fn load() -> Result<Self> {
let path = state_path();
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(&path).context("reading installed.json")?;
serde_json::from_str(&text).context("parsing installed.json")
}
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")
}
/// Runs `f` against a freshly-loaded `State` while holding an exclusive
/// lock on a sibling `installed.json.lock` file, saving the result if `f`
/// succeeds. Without this, two concurrent `bakery` invocations each
/// load-mutate-save `installed.json` independently and the second save
/// silently drops the first's change — the lock serializes the whole
/// read-modify-write instead of just the final write.
pub fn with_lock<T>(f: impl FnOnce(&mut State) -> Result<T>) -> Result<T> {
let lock_path = PathBuf::from(format!("{}.lock", state_path().display()));
if let Some(dir) = lock_path.parent() {
std::fs::create_dir_all(dir)?;
}
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.context("opening installed.json.lock")?;
lock_file
.lock_exclusive()
.context("locking installed.json.lock")?;
let mut state = Self::load()?;
let result = f(&mut state)?;
state.save()?;
// Lock releases when `lock_file` drops at end of scope.
Ok(result)
}
pub fn is_installed(&self, name: &str) -> bool {
self.packages.contains_key(name)
}
pub fn record(&mut self, pkg: InstalledPackage) {
self.packages.insert(pkg.name.clone(), pkg);
}
pub fn remove(&mut self, name: &str) -> Option<InstalledPackage> {
self.packages.remove(name)
}
pub fn set_track(&mut self, track: Track) {
self.track = track;
}
}
fn state_base_dir() -> PathBuf {
dirs::state_dir().unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".local/state")
})
}
fn state_path() -> PathBuf {
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
/// `install::install_package` right before an update overwrites the
/// previous binaries and consumed by `bakery rollback`. See
/// `install::backup_current_binary`'s doc comment for why this is a local
/// snapshot rather than a re-fetch of the old version from the server.
pub fn backup_dir(pkg_name: &str, version: &str) -> PathBuf {
state_base_dir()
.join("bakery/backups")
.join(pkg_name)
.join(version)
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, version: &str) -> InstalledPackage {
InstalledPackage {
name: name.to_string(),
version: version.to_string(),
binaries: vec![],
services: vec![],
installed_at: "2026-01-01T00:00:00Z".to_string(),
track: Track::Stable,
previous_version: None,
binary_sha256: HashMap::new(),
}
}
#[test]
fn record_and_is_installed() {
let mut state = State::default();
assert!(!state.is_installed("foo"));
state.record(pkg("foo", "1.0.0"));
assert!(state.is_installed("foo"));
}
#[test]
fn remove_installed() {
let mut state = State::default();
state.record(pkg("foo", "1.0.0"));
let removed = state.remove("foo");
assert!(removed.is_some());
assert!(!state.is_installed("foo"));
}
#[test]
fn remove_unknown_returns_none() {
let mut state = State::default();
assert!(state.remove("nope").is_none());
}
#[test]
fn track_defaults_to_stable_on_old_shape_json() {
// Simulates installed.json written before the track field existed.
let old_shape = r#"{"packages":{}}"#;
let state: State = serde_json::from_str(old_shape).unwrap();
assert_eq!(state.track, Track::Stable);
}
#[test]
fn set_track_updates_and_roundtrips() {
let mut state = State::default();
state.set_track(Track::Dev);
let json = serde_json::to_string(&state).unwrap();
let restored: State = serde_json::from_str(&json).unwrap();
assert_eq!(restored.track, Track::Dev);
}
#[test]
fn json_roundtrip() {
let mut state = State::default();
state.record(InstalledPackage {
name: "bar".to_string(),
version: "2.0.0".to_string(),
binaries: vec!["bar".to_string()],
services: vec!["bar.service".to_string()],
installed_at: "2026-06-01T00:00:00Z".to_string(),
track: Track::Beta,
previous_version: Some("1.0.0".to_string()),
binary_sha256: HashMap::from([("bar".to_string(), "abc123".to_string())]),
});
let json = serde_json::to_string(&state).unwrap();
let restored: State = serde_json::from_str(&json).unwrap();
assert!(restored.is_installed("bar"));
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"].binary_sha256["bar"], "abc123");
}
#[test]
fn installed_package_track_defaults_to_stable_on_old_shape_json() {
// Simulates an installed.json entry written before per-package track
// tracking existed.
let old_shape = r#"{"name":"foo","version":"1.0.0","binaries":[],"services":[],"installed_at":"2026-01-01T00:00:00Z"}"#;
let installed: InstalledPackage = serde_json::from_str(old_shape).unwrap();
assert_eq!(installed.track, Track::Stable);
}
#[test]
fn installed_package_previous_version_and_binary_sha256_default_on_old_shape_json() {
// Simulates an installed.json entry written before rollback/verify
// support existed.
let old_shape = r#"{"name":"foo","version":"1.0.0","binaries":[],"services":[],"installed_at":"2026-01-01T00:00:00Z","track":"stable"}"#;
let installed: InstalledPackage = serde_json::from_str(old_shape).unwrap();
assert!(installed.previous_version.is_none());
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");
let b = backup_dir("bakery", "0.3.2");
let c = backup_dir("breadhelp", "0.3.1");
assert_ne!(a, b);
assert_ne!(a, c);
assert!(a.ends_with("bakery/backups/bakery/0.3.1"));
}
#[test]
fn with_lock_persists_mutation_across_reload() {
let dir = tempfile::tempdir().unwrap();
// SAFETY (test-only): temporarily redirects the state dir env var so
// this test doesn't touch the real ~/.local/state/bakery/installed.json.
std::env::set_var("XDG_STATE_HOME", dir.path());
State::with_lock(|state| {
state.record(pkg("foo", "1.0.0"));
Ok(())
})
.unwrap();
let reloaded = State::load().unwrap();
assert!(reloaded.is_installed("foo"));
std::env::remove_var("XDG_STATE_HOME");
}
}