bread-ecosystem/bakery/src/manifest.rs
Breadway 694829c50f fix: comprehensive bakery package manager audit and repair
Critical fixes:
- gen-index.sh: emit services, config, optional_system_deps from bakery.toml;
  parse product list from registry TOML instead of hardcoded array; fail loudly
  when bakery.toml is missing (was silently producing empty metadata in prod)
- install.rs: download service units and example configs from dl server at
  install time (were never fetched); check systemctl exit codes (were swallowed);
  save state before file cleanup in remove_package (was inconsistent on error)
- doctor.rs: rewrite dep detection to use `pacman -Q` as primary (no more
  dependency on `which` or pkg-config name mismatches); add optional_system_deps
  support returning (missing, warnings) — warnings print but never block install
- get.sh: fix GitHub fallback URL (was 404 for both latest and versioned
  releases); add SHA-256 checksum verification using published .sha256 file

High priority fixes:
- bakery doctor <unknown-pkg>: exit non-zero (was silently passing)
- bakery update: add --all flag (documented in README but missing from CLI);
  add doctor gate before update (was bypassing dep check)
- bread_deps: now resolved recursively with cycle detection (was ignored)
- manifest.rs: add artifact_urls() helper and optional_system_deps field
- state.rs: atomic save via tmp+rename; cmd_info shows optional_system_deps

Tests: 17 new unit tests across doctor, download, install, state modules;
scripts/test-gen-index.sh fixture test for full pipeline
2026-06-11 13:37:09 +08:00

153 lines
4.4 KiB
Rust

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
const PRIMARY_URL: &str = "https://dl.breadway.dev/index.json";
const CACHE_MAX_AGE: Duration = Duration::from_secs(24 * 3600);
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Binary {
pub name: String,
pub dl_url: String,
pub github_url: String,
pub sha256: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Service {
pub unit: String,
pub enable: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ConfigScaffold {
pub dir: String,
/// Example config filename, relative to the release artifact directory.
pub example: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Package {
pub name: String,
pub description: String,
pub version: String,
pub binaries: Vec<Binary>,
#[serde(default)]
pub system_deps: Vec<String>,
#[serde(default)]
pub optional_system_deps: Vec<String>,
#[serde(default)]
pub bread_deps: Vec<String>,
#[serde(default)]
pub services: Vec<Service>,
pub config: Option<ConfigScaffold>,
#[serde(default)]
pub post_install: Vec<String>,
}
impl Package {
/// Returns `(primary_url, github_url)` for any artifact filename in this
/// package's release directory. Derived by stripping the filename from the
/// first binary's URLs.
pub fn artifact_urls(&self, filename: &str) -> Option<(String, String)> {
let first = self.binaries.first()?;
let dl_base = first.dl_url.rsplit_once('/')?.0;
let gh_base = first.github_url.rsplit_once('/')?.0;
Some((
format!("{dl_base}/{filename}"),
format!("{gh_base}/{filename}"),
))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Index {
pub version: String,
pub packages: std::collections::HashMap<String, Package>,
}
impl Index {
pub fn get(&self, name: &str) -> Option<&Package> {
self.packages.get(name)
}
#[allow(dead_code)]
pub fn all(&self) -> impl Iterator<Item = &Package> {
self.packages.values()
}
}
/// Load the manifest, using the on-disk cache when it is fresh enough.
/// Always fetches if `force_refresh` is true.
pub fn load(force_refresh: bool) -> Result<Index> {
let cache_path = cache_path();
if !force_refresh && cache_is_fresh(&cache_path) {
let text = std::fs::read_to_string(&cache_path).context("reading cached index")?;
return serde_json::from_str(&text).context("parsing cached index");
}
fetch_and_cache(&cache_path)
}
fn cache_is_fresh(path: &PathBuf) -> bool {
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| SystemTime::now().duration_since(t).unwrap_or(CACHE_MAX_AGE) < CACHE_MAX_AGE)
.unwrap_or(false)
}
fn fetch_and_cache(cache_path: &PathBuf) -> Result<Index> {
let text = fetch_text(PRIMARY_URL)?;
if let Some(dir) = cache_path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(cache_path, &text)?;
serde_json::from_str(&text).context("parsing index.json")
}
fn fetch_text(url: &str) -> Result<String> {
ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("{e}"))?
.into_string()
.context("reading response body")
}
pub fn cache_path() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("~/.cache"))
.join("bakery/index.json")
}
/// Download a binary blob from `primary_url`, falling back to `fallback_url`
/// on any network error. Returns the raw bytes.
pub fn fetch_binary(primary_url: &str, fallback_url: &str) -> Result<Vec<u8>> {
match fetch_bytes(primary_url) {
Ok(bytes) => Ok(bytes),
Err(primary_err) => {
eprintln!(
" primary URL failed ({}), trying GitHub fallback…",
primary_err
);
fetch_bytes(fallback_url).context("both primary and GitHub fallback failed")
}
}
}
fn fetch_bytes(url: &str) -> Result<Vec<u8>> {
use std::io::Read;
let resp = ureq::get(url)
.call()
.map_err(|e| anyhow::anyhow!("{e}"))?;
let status = resp.status();
if status != 200 {
bail!("HTTP {status} from {url}");
}
let mut buf = Vec::new();
resp.into_reader()
.read_to_end(&mut buf)
.context("reading response")?;
Ok(buf)
}