car-inference 0.24.1

Local model inference for CAR — Candle backend with Qwen3 models
//! Refreshable signed model catalog (Phase E1).
//!
//! The built-in catalog (`builtin_catalog.json`) is fixed at build time,
//! so a genuinely new model — say a new small-active-param MoE worth
//! trying on a constrained machine — can't surface until the next
//! release. This lets the catalog be refreshed from a **signed** remote
//! source: the daemon fetches the catalog bytes + a detached ed25519
//! signature, verifies it against a pinned public key, and caches the
//! verified models. They load into the registry at next startup (the
//! registry is immutable at runtime), becoming `recommend()` candidates
//! and therefore concierge suggestions — always grounded, never
//! hallucinated.
//!
//! Security: a catalog is only ever trusted if its signature verifies
//! against the configured public key. No key configured → no remote
//! catalog (safe default). Verification is over the exact fetched bytes
//! (detached signature), so there's no canonicalization to get wrong.

use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::schema::ModelSchema;

/// Cache file name (sibling of `models.json` under `~/.car/`).
pub const CATALOG_CACHE_FILE: &str = "catalog-cache.json";

/// Max catalog body we'll download before verifying — a backstop so a
/// malicious/oversized URL can't OOM the daemon ahead of verification.
const MAX_CATALOG_BYTES: u64 = 8 * 1024 * 1024;

/// The signed catalog document. `version` is a monotonic counter the
/// publisher increments each release; the daemon refuses any catalog
/// whose version is not strictly greater than the last accepted one.
/// This is the anti-rollback guard: a signature proves authenticity but
/// not freshness, so an attacker (or stale cache) replaying an *old but
/// validly signed* catalog would otherwise pass — the version check
/// stops it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogDoc {
    pub version: u64,
    #[serde(default)]
    pub models: Vec<ModelSchema>,
}

/// Where the verified catalog is cached, given the models dir.
pub fn cache_path(models_dir: &Path) -> PathBuf {
    models_dir
        .parent()
        .unwrap_or(models_dir)
        .join(CATALOG_CACHE_FILE)
}

/// Load cached, previously-verified catalog models. Best-effort: a
/// missing/garbage cache yields an empty list (never breaks startup).
/// Only the *write* path verifies signatures — the cache only ever
/// contains bytes that already passed verification.
pub fn load_cache(path: &Path) -> Vec<ModelSchema> {
    load_doc(path).map(|d| d.models).unwrap_or_default()
}

/// Load the cached catalog document (for the version check), if present.
pub fn load_doc(path: &Path) -> Option<CatalogDoc> {
    let s = std::fs::read_to_string(path).ok()?;
    serde_json::from_str(&s).ok()
}

/// Fetch a catalog document + its detached signature, verify against
/// `public_key_b64`, and return the parsed `CatalogDoc`. The signature is
/// `{url}.sig` (base64 ed25519 over the exact catalog bytes). The caller
/// enforces version monotonicity (anti-rollback).
pub async fn fetch_and_verify(
    http: &reqwest::Client,
    url: &str,
    public_key_b64: &str,
) -> Result<CatalogDoc, String> {
    let resp = http
        .get(url)
        .send()
        .await
        .map_err(|e| format!("fetch catalog: {e}"))?
        .error_for_status()
        .map_err(|e| format!("fetch catalog: {e}"))?;
    if let Some(len) = resp.content_length() {
        if len > MAX_CATALOG_BYTES {
            return Err(format!("catalog too large ({len} bytes)"));
        }
    }
    let bytes = resp
        .bytes()
        .await
        .map_err(|e| format!("read catalog: {e}"))?;
    if bytes.len() as u64 > MAX_CATALOG_BYTES {
        return Err(format!("catalog too large ({} bytes)", bytes.len()));
    }
    let sig = http
        .get(format!("{url}.sig"))
        .send()
        .await
        .map_err(|e| format!("fetch signature: {e}"))?
        .error_for_status()
        .map_err(|e| format!("fetch signature: {e}"))?
        .text()
        .await
        .map_err(|e| format!("read signature: {e}"))?;

    car_bundle::verify_detached(&bytes, sig.trim(), public_key_b64)
        .map_err(|e| format!("catalog signature verification failed: {e}"))?;

    serde_json::from_slice(&bytes).map_err(|e| format!("parse catalog: {e}"))
}

/// Persist the verified catalog document to the cache (atomic temp+rename).
pub fn save_doc(path: &Path, doc: &CatalogDoc) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let json = serde_json::to_string_pretty(doc)
        .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
    let tmp = path.with_extension("json.tmp");
    std::fs::write(&tmp, json)?;
    std::fs::rename(&tmp, path)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn missing_cache_is_empty() {
        let p = std::env::temp_dir().join("car-catalog-none-xyz.json");
        let _ = std::fs::remove_file(&p);
        assert!(load_cache(&p).is_empty());
    }

    #[test]
    fn cache_path_is_sibling_of_models_dir() {
        let p = cache_path(Path::new("/home/u/.car/models"));
        assert_eq!(p, Path::new("/home/u/.car/catalog-cache.json"));
    }
}