Store: two catalog files from one source, so apps from before a kind keep a working Store

An app that does not know a catalog item's kind refuses the whole signed file, so
the icons and builtin kinds can never go into catalog.json. `waybetter store build`
now writes catalog.json (format 2: theme, plugin, strings only; what every app
reads today) and catalog.v3.json (format 3: every kind) from the same items;
`sign` and `verify` handle the pair; the rules refuse a format 2 file that lists a
newer kind (CLI, JSON Schema, host). The host reads catalog.v3.json first and falls
back to catalog.json only when the newer file is not there (a newer file that does
not verify is an error). An item of a kind a version does not know is skipped
instead of refusing the file, so the next new kind costs current apps nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-10-01 14:41:29 +07:00
parent ec548a53d4
commit 3db5435208
6 changed files with 165 additions and 35 deletions

View file

@ -17,8 +17,15 @@ pub const DEFAULT_HOSTS: &[AllowedHost] = &[
AllowedHost { host: "cdn1.crt.fyi", raw_files_only: false },
AllowedHost { host: "git.crt.fyi", raw_files_only: true },
];
/// The catalog format this code reads. Format 2: every item names the apps it is for (`apps`).
/// The catalog format every app reads. Format 2: every item names the apps it is for (`apps`), and only
/// the kinds every app knows (theme, plugin, strings): an app from before a kind existed refuses a
/// whole catalog that lists it, so `catalog.json` never does.
pub const CATALOG_FORMAT: u32 = 2;
/// Format 3 is the same file with every kind (icons, builtin, ...), as `catalog.v3.json`. Apps that
/// know it read it first and fall back to `catalog.json` when it is not published.
pub const CATALOG_FORMAT_NEXT: u32 = 3;
pub const CATALOG_FILE: &str = "catalog.json";
pub const CATALOG_FILE_NEXT: &str = "catalog.v3.json";
pub const MAX_THEME_BYTES: usize = 2 * 1024 * 1024;
/// An icon pack file (the runtime's `ICON_LIMITS.packBytes`).
pub const MAX_ICONS_BYTES: usize = 120_000;
@ -82,6 +89,11 @@ impl Kind {
}
}
/// Kinds every app knows, the only ones a format 2 catalog may list.
pub fn is_legacy(self) -> bool {
matches!(self, Kind::Theme | Kind::Plugin | Kind::Strings)
}
pub fn as_str(self) -> &'static str {
match self {
Kind::Theme => "theme",
@ -380,6 +392,16 @@ pub struct CatalogItem {
pub credits: Option<String>,
}
/// The catalog as signed, before its items are looked at one by one.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RawCatalog {
format: u32,
name: String,
generated_at: String,
items: Vec<serde_json::Value>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Catalog {
@ -539,13 +561,27 @@ pub fn verify_catalog(config: &StoreConfig, body: &[u8], signature: &str) -> Res
if !verified {
return Err("STORE: the catalog signature does not verify".into());
}
let catalog: Catalog = serde_json::from_slice(body).map_err(|error| format!("STORE: catalog schema: {error}"))?;
if catalog.format != CATALOG_FORMAT {
let raw: RawCatalog = serde_json::from_slice(body).map_err(|error| format!("STORE: catalog schema: {error}"))?;
if raw.format != CATALOG_FORMAT && raw.format != CATALOG_FORMAT_NEXT {
return Err("STORE: unsupported catalog format (update the app)".into());
}
if catalog.items.len() > MAX_ITEMS {
if raw.items.len() > MAX_ITEMS {
return Err("STORE: too many items".into());
}
// An item of a kind this version does not know is skipped, not a reason to refuse the whole
// signed file (a later catalog may list kinds this version has no use for). Everything else
// about an item is held to the rules below, as ever.
let mut items = Vec::with_capacity(raw.items.len());
for value in raw.items {
if value.get("kind").and_then(serde_json::Value::as_str).is_some_and(|kind| Kind::parse(kind).is_err()) {
continue;
}
items.push(serde_json::from_value::<CatalogItem>(value).map_err(|error| format!("STORE: catalog schema: {error}"))?);
}
if raw.format == CATALOG_FORMAT && items.iter().any(|item| !item.kind.is_legacy()) {
return Err("STORE: a format 2 catalog lists only theme, plugin and strings items".into());
}
let catalog = Catalog { format: raw.format, name: raw.name, generated_at: raw.generated_at, items };
let mut ids = HashSet::new();
let mut files = HashSet::new();
for item in &catalog.items {
@ -745,7 +781,11 @@ pub(crate) mod tests {
}
pub(crate) fn catalog_json(items: serde_json::Value) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({ "format": CATALOG_FORMAT, "name": "Test", "generatedAt": "2026-09-28T00:00:00Z", "items": items })).unwrap()
catalog_json_format(CATALOG_FORMAT, items)
}
pub(crate) fn catalog_json_format(format: u32, items: serde_json::Value) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({ "format": format, "name": "Test", "generatedAt": "2026-09-28T00:00:00Z", "items": items })).unwrap()
}
pub(crate) fn plugin_item(body: &[u8]) -> serde_json::Value {
@ -908,8 +948,14 @@ pub(crate) mod tests {
fn icon_packs_and_built_in_items_are_catalog_kinds() {
let (key, public) = test_key(7);
let c = config("x", &[public]);
let body = catalog_json(serde_json::json!([icons_item(ICONS), builtin_item()]));
let body = catalog_json_format(CATALOG_FORMAT_NEXT, serde_json::json!([icons_item(ICONS), builtin_item()]));
let catalog = verify_catalog(c, &body, &sign(&key, &body)).unwrap();
// The newer kinds are a format 3 catalog only: a format 2 file that lists them is refused, so
// the file older apps read can never carry a kind they would refuse.
let old = catalog_json(serde_json::json!([icons_item(ICONS)]));
assert!(verify_catalog(c, &old, &sign(&key, &old)).unwrap_err().contains("format 2"));
let both = catalog_json_format(CATALOG_FORMAT_NEXT, serde_json::json!([plugin_item(PLUGIN)]));
assert_eq!(verify_catalog(c, &both, &sign(&key, &both)).unwrap().format, CATALOG_FORMAT_NEXT);
assert_eq!(catalog.items.iter().map(|item| item.kind).collect::<Vec<_>>(), [Kind::Icons, Kind::Builtin]);
verify_item(&catalog.items[0], ICONS).unwrap();
assert!(verify_item(&catalog.items[0], b"{}").is_err());
@ -950,14 +996,23 @@ pub(crate) mod tests {
big["size"] = serde_json::json!(MAX_ICONS_BYTES + 1);
cases.push(("icon pack over the cap", big));
for (what, item) in cases {
let body = catalog_json(serde_json::json!([item]));
let body = catalog_json_format(CATALOG_FORMAT_NEXT, serde_json::json!([item]));
assert!(verify_catalog(c, &body, &sign(&key, &body)).is_err(), "accepted bad {what}");
}
// Another app's built-in items are not this app's.
let mut other = builtin_item();
other["apps"] = serde_json::json!(["kick"]);
let body = catalog_json(serde_json::json!([other]));
let body = catalog_json_format(CATALOG_FORMAT_NEXT, serde_json::json!([other]));
assert!(verify_catalog(c, &body, &sign(&key, &body)).unwrap().items.is_empty());
// A kind this version has never heard of is skipped (a later catalog may list it); a malformed
// item of a known kind, or an item without a kind, still refuses the file.
let future = serde_json::json!({ "apps": ["x"], "kind": "widget", "id": "w", "name": "W", "version": "1", "extra": [1, 2] });
let body = catalog_json_format(CATALOG_FORMAT_NEXT, serde_json::json!([future.clone(), plugin_item(PLUGIN)]));
assert_eq!(verify_catalog(c, &body, &sign(&key, &body)).unwrap().items.iter().map(|item| item.id.as_str()).collect::<Vec<_>>(), ["tool"]);
let mut kindless = future;
kindless.as_object_mut().unwrap().remove("kind");
let body = catalog_json_format(CATALOG_FORMAT_NEXT, serde_json::json!([kindless]));
assert!(verify_catalog(c, &body, &sign(&key, &body)).is_err());
// Links and the shell's kind names: icons yes, built-in never.
assert_eq!(parse_install_link(c, "waybetter://install?kind=icons&url=https://waybetter.fyi/store/icons/line-2.icons.json").unwrap().kind, Kind::Icons);
assert!(parse_install_link(c, "waybetter://install?kind=builtin&url=https://waybetter.fyi/a.json").unwrap_err().contains("built-in"));

View file

@ -1,6 +1,6 @@
//! Store downloads (`net` feature): allow-listed https only, no redirects, no cookies, size caps.
use super::catalog::{self as dist, StoreConfig, MAX_CATALOG_BYTES, MAX_SIGNATURE_BYTES};
use super::catalog::{self as dist, StoreConfig, CATALOG_FILE, CATALOG_FILE_NEXT, MAX_CATALOG_BYTES, MAX_SIGNATURE_BYTES};
use super::install::{InstallPreview, Store, StoreItemRef, StoreView};
use std::sync::OnceLock;
use std::time::Duration;
@ -48,6 +48,13 @@ pub async fn fetch(config: &StoreConfig, url: &str, max: usize) -> Result<Vec<u8
Ok(bytes)
}
/// A catalog file and its detached signature.
async fn fetch_pair(config: &StoreConfig, url: &str) -> Result<(Vec<u8>, String), String> {
let body = fetch(config, url, MAX_CATALOG_BYTES).await?;
let signature = fetch(config, &format!("{url}.sig"), MAX_SIGNATURE_BYTES).await?;
Ok((body, String::from_utf8(signature).map_err(|_| "STORE: invalid signature")?))
}
impl Store {
/// Downloads what an install link points at and stages it for the confirmation screen.
pub async fn stage_link(&self, kind: &str, url: &str) -> Result<InstallPreview, String> {
@ -79,12 +86,16 @@ impl Store {
self.view(error)
}
/// The newer catalog first (`catalog.v3.json`: every kind), then `catalog.json` (what every app
/// knows) when the newer one is not there. A newer catalog that is there but does not verify is an
/// error, never a reason to fall back.
async fn fetch_catalog(&self) -> Result<(), String> {
let base = self.config().store_base;
let body = fetch(self.config(), &format!("{base}catalog.json"), MAX_CATALOG_BYTES).await?;
let signature = fetch(self.config(), &format!("{base}catalog.json.sig"), MAX_SIGNATURE_BYTES).await?;
let signature = String::from_utf8(signature).map_err(|_| "STORE: invalid signature")?;
self.accept_catalog(&body, &signature)
let pair = match fetch_pair(self.config(), &format!("{base}{CATALOG_FILE_NEXT}")).await {
Ok(pair) => pair,
Err(_) => fetch_pair(self.config(), &format!("{base}{CATALOG_FILE}")).await?,
};
self.accept_catalog(&pair.0, &pair.1)
}
}