Skip to main content

cookie_agent_models/catalog/
manager.rs

1use std::{path::Path, sync::Arc};
2
3use cookie_agent_identity::CatalogRevision;
4use futures_util::StreamExt as _;
5use jiff::Timestamp;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest as _, Sha256};
8use thiserror::Error;
9
10use crate::secure_store::{
11    DEFAULT_LOCK_BUDGET, SecureDirectory, SecureDirectoryLock, SecureStoreError,
12};
13
14use super::{
15    CATALOG_BODY_FILE, CATALOG_CACHE_SCHEMA_VERSION, CATALOG_LOCK_FILE, CATALOG_MAX_BYTES,
16    CATALOG_META_FILE, CatalogAgeState, CatalogAvailability, CatalogCacheMeta, CatalogRequest,
17    CatalogRuntimeState, CatalogSafeErrorMeta, CatalogSnapshot, CatalogSource, CatalogTransport,
18    CatalogTransportError, CatalogTransportResponse, MODELS_DEV_CATALOG_URL, parse_cache_meta,
19    parse_catalog, validated_bootstrap,
20};
21
22const MAX_META_BYTES: u64 = 128 * 1024;
23const MAX_JOURNAL_BYTES: u64 = 16 * 1024;
24const MAX_ETAG_BYTES: usize = 1_024;
25const MAX_SAFE_MESSAGE_BYTES: usize = 512;
26const SEVEN_DAYS_SECONDS: i64 = 7 * 24 * 60 * 60;
27const THIRTY_DAYS_SECONDS: i64 = 30 * 24 * 60 * 60;
28const BODY_NEXT_FILE: &str = ".models-dev-v2.json.next";
29const META_NEXT_FILE: &str = ".models-dev-v2.meta.json.next";
30const BODY_BACKUP_FILE: &str = ".models-dev-v2.json.backup";
31const META_BACKUP_FILE: &str = ".models-dev-v2.meta.json.backup";
32
33/// Dynamic network/cache/bootstrap catalog manager.
34pub struct CatalogManager<T> {
35    transport: Arc<T>,
36    cache: Result<SecureDirectory, CatalogError>,
37    #[cfg(all(test, unix))]
38    commit_failure: std::sync::Mutex<Option<CacheCommitPhase>>,
39}
40
41impl<T: CatalogTransport> CatalogManager<T> {
42    /// Uses the fixed per-user catalog cache path.
43    pub fn standard(transport: T) -> Self {
44        Self {
45            transport: Arc::new(transport),
46            cache: SecureDirectory::user_data("catalog").map_err(CatalogError::from_store),
47            #[cfg(all(test, unix))]
48            commit_failure: std::sync::Mutex::new(None),
49        }
50    }
51
52    /// Uses an explicit secure directory, primarily for deterministic tests.
53    #[must_use]
54    pub fn new(transport: T, cache: SecureDirectory) -> Self {
55        Self {
56            transport: Arc::new(transport),
57            cache: Ok(cache),
58            #[cfg(all(test, unix))]
59            commit_failure: std::sync::Mutex::new(None),
60        }
61    }
62
63    /// Opens an explicit private cache below a trusted anchor.
64    pub fn in_directory(
65        transport: T,
66        anchor: impl AsRef<Path>,
67        relative: impl AsRef<Path>,
68    ) -> Self {
69        Self {
70            transport: Arc::new(transport),
71            cache: SecureDirectory::open_in(anchor, relative).map_err(CatalogError::from_store),
72            #[cfg(all(test, unix))]
73            commit_failure: std::sync::Mutex::new(None),
74        }
75    }
76
77    /// The catalog to start from without touching the network: the validated
78    /// cache, else the bundled bootstrap copy. A background refresh then
79    /// checks models.dev.
80    pub fn load_cached(&self) -> Result<CatalogSnapshot, CatalogError> {
81        self.load_cached_at(Timestamp::now())
82    }
83
84    /// [`Self::load_cached`] at a supplied time for deterministic tests.
85    pub fn load_cached_at(&self, now: Timestamp) -> Result<CatalogSnapshot, CatalogError> {
86        match self.load_cache(now) {
87            Ok(cached) => {
88                let availability = if cached.meta.stale {
89                    CatalogAvailability::Stale
90                } else {
91                    CatalogAvailability::Ready
92                };
93                Ok(snapshot_from_parsed(
94                    cached.parsed,
95                    CatalogSource::Cache,
96                    cached.meta.validated_at,
97                    cached.meta.last_checked_at,
98                    cached.meta.etag,
99                    availability,
100                    cached.meta.last_error,
101                ))
102            }
103            Err(_) => {
104                let parsed = parse_catalog(validated_bootstrap()?)?;
105                Ok(snapshot_from_parsed(
106                    parsed,
107                    CatalogSource::Bootstrap,
108                    now,
109                    now,
110                    None,
111                    CatalogAvailability::Bootstrap,
112                    None,
113                ))
114            }
115        }
116    }
117
118    /// Refreshes using the system clock.
119    pub async fn refresh(&self) -> Result<CatalogSnapshot, CatalogError> {
120        self.refresh_at(Timestamp::now()).await
121    }
122
123    /// Refreshes at a supplied time for deterministic tests.
124    pub async fn refresh_at(&self, now: Timestamp) -> Result<CatalogSnapshot, CatalogError> {
125        let cache = self.load_cache(now);
126        let etag = cache
127            .as_ref()
128            .ok()
129            .and_then(|cache| cache.meta.etag.clone());
130        let network = match self.transport.fetch(CatalogRequest::fixed(etag)).await {
131            Ok(response) => self.validate_response(response),
132            Err(error) => Err(CatalogError::from_transport(error)),
133        };
134        let network = match network {
135            Ok(result) => Self::finish_stream(result).await,
136            Err(error) => Err(error),
137        };
138
139        match network {
140            Ok(NetworkResult::NotModified) => {
141                let cached = match cache {
142                    Ok(cached) => cached,
143                    Err(cache_error) => {
144                        return self.select_fallback(
145                            Err(cache_error),
146                            CatalogError::new(
147                                "not_modified_without_valid_cache",
148                                "catalog server returned not modified without a valid cache",
149                            ),
150                            now,
151                        );
152                    }
153                };
154                let mut meta = cached.meta;
155                meta.validated_at = now;
156                meta.last_checked_at = now;
157                meta.selected_source = CatalogSource::Network;
158                meta.stale = false;
159                meta.last_error = None;
160                let write_error = self.commit_meta(&meta).err();
161                Ok(snapshot_from_parsed(
162                    cached.parsed,
163                    CatalogSource::Network,
164                    meta.validated_at,
165                    now,
166                    meta.etag.clone(),
167                    CatalogAvailability::Ready,
168                    write_error.map(|error| error.safe_meta(now)),
169                ))
170            }
171            Ok(NetworkResult::Body { bytes, etag }) => {
172                let parsed = match parse_catalog(&bytes) {
173                    Ok(parsed) => parsed,
174                    Err(error) => return self.select_fallback(cache, error, now),
175                };
176                let mut meta = metadata_for(
177                    &parsed,
178                    CatalogSource::Network,
179                    false,
180                    now,
181                    now,
182                    etag.clone(),
183                    None,
184                );
185                let write_error = self.commit_cache(&bytes, &meta).err();
186                if let Some(error) = &write_error {
187                    meta.last_error = Some(error.safe_meta(now));
188                }
189                Ok(snapshot_from_parsed(
190                    parsed,
191                    CatalogSource::Network,
192                    now,
193                    now,
194                    etag,
195                    CatalogAvailability::Ready,
196                    meta.last_error,
197                ))
198            }
199            Ok(NetworkResult::Streaming { .. }) => Err(CatalogError::new(
200                "catalog_internal_stream_state",
201                "catalog response stream was not finalized",
202            )),
203            Err(network_error) => self.select_fallback(cache, network_error, now),
204        }
205    }
206
207    fn select_fallback(
208        &self,
209        cache: Result<ValidatedCache, CatalogError>,
210        network_error: CatalogError,
211        now: Timestamp,
212    ) -> Result<CatalogSnapshot, CatalogError> {
213        match cache {
214            Ok(cached) => {
215                let safe_error = network_error.safe_meta(now);
216                let mut meta = metadata_for(
217                    &cached.parsed,
218                    CatalogSource::Cache,
219                    true,
220                    cached.meta.validated_at,
221                    now,
222                    cached.meta.etag.clone(),
223                    Some(safe_error),
224                );
225                let write_error = self.commit_meta(&meta).err();
226                if let Some(error) = write_error {
227                    meta.last_error = Some(error.safe_meta(now));
228                }
229                Ok(snapshot_from_parsed(
230                    cached.parsed,
231                    CatalogSource::Cache,
232                    meta.validated_at,
233                    now,
234                    meta.etag,
235                    CatalogAvailability::Stale,
236                    meta.last_error,
237                ))
238            }
239            Err(cache_error) => {
240                let bootstrap = validated_bootstrap()?;
241                let parsed = parse_catalog(bootstrap)?;
242                let fallback = CatalogError::new(
243                    "catalog_bootstrap_fallback",
244                    format!(
245                        "network catalog and validated cache were unavailable ({}, {})",
246                        network_error.code(),
247                        cache_error.code()
248                    ),
249                );
250                let mut meta = metadata_for(
251                    &parsed,
252                    CatalogSource::Bootstrap,
253                    true,
254                    now,
255                    now,
256                    None,
257                    Some(fallback.safe_meta(now)),
258                );
259                let write_error = self.commit_cache(bootstrap, &meta).err();
260                if let Some(error) = write_error {
261                    meta.last_error = Some(error.safe_meta(now));
262                }
263                Ok(snapshot_from_parsed(
264                    parsed,
265                    CatalogSource::Bootstrap,
266                    now,
267                    now,
268                    None,
269                    CatalogAvailability::Bootstrap,
270                    meta.last_error,
271                ))
272            }
273        }
274    }
275
276    fn validate_response(
277        &self,
278        response: CatalogTransportResponse,
279    ) -> Result<NetworkResult, CatalogError> {
280        if (300..400).contains(&response.status) && response.status != 304 {
281            return Err(CatalogError::new(
282                "catalog_redirect_rejected",
283                "catalog redirects are forbidden",
284            ));
285        }
286        if response.status == 304 {
287            return Ok(NetworkResult::NotModified);
288        }
289        if response.status != 200 {
290            return Err(CatalogError::new(
291                "catalog_http_status",
292                "catalog server returned an unusable status",
293            ));
294        }
295        if response
296            .content_encoding
297            .as_deref()
298            .is_some_and(|encoding| !encoding.trim().eq_ignore_ascii_case("identity"))
299        {
300            return Err(CatalogError::new(
301                "catalog_compression_rejected",
302                "compressed catalog responses are forbidden",
303            ));
304        }
305        let json_content_type = response
306            .content_type
307            .as_deref()
308            .is_some_and(|content_type| {
309                content_type.split(';').next().is_some_and(|media_type| {
310                    media_type.trim().eq_ignore_ascii_case("application/json")
311                })
312            });
313        if !json_content_type {
314            return Err(CatalogError::new(
315                "catalog_content_type_rejected",
316                "catalog response is not JSON",
317            ));
318        }
319        if response
320            .content_length
321            .is_some_and(|length| length > CATALOG_MAX_BYTES as u64)
322        {
323            return Err(CatalogError::new(
324                "catalog_body_too_large",
325                "catalog response exceeds the byte limit",
326            ));
327        }
328        let etag = response.etag.map(validate_etag).transpose()?;
329        Ok(NetworkResult::Streaming {
330            body: response.body,
331            capacity: response
332                .content_length
333                .and_then(|length| usize::try_from(length).ok())
334                .unwrap_or(0),
335            etag,
336        })
337    }
338
339    fn load_cache(&self, _now: Timestamp) -> Result<ValidatedCache, CatalogError> {
340        let directory = self.cache.as_ref().map_err(Clone::clone)?;
341        let lock = directory
342            .lock_within(CATALOG_LOCK_FILE, DEFAULT_LOCK_BUDGET)
343            .map_err(CatalogError::from_store)?;
344        recover_cache(&lock)?;
345        let body = lock
346            .read(CATALOG_BODY_FILE, CATALOG_MAX_BYTES as u64)
347            .map_err(CatalogError::from_store)?
348            .ok_or_else(|| {
349                CatalogError::new("catalog_cache_missing", "catalog cache body is missing")
350            })?;
351        let meta_bytes = lock
352            .read(CATALOG_META_FILE, MAX_META_BYTES)
353            .map_err(CatalogError::from_store)?
354            .ok_or_else(|| {
355                CatalogError::new("catalog_cache_missing", "catalog cache metadata is missing")
356            })?;
357        let meta = parse_cache_meta(&meta_bytes)?;
358        // Parsing computes the body's revision, the one hash of it this load
359        // needs; the metadata is checked against that.
360        let parsed = parse_catalog(&body)?;
361        validate_meta(&meta, body.len(), &parsed.revision)?;
362        validate_parsed_meta(&meta, &parsed)?;
363        Ok(ValidatedCache { parsed, meta })
364    }
365
366    /// Rewrites only the metadata for a body that is already installed (a 304,
367    /// or serving the cache after a failed refresh), instead of rewriting the
368    /// multi-megabyte body. The metadata on disk must still name the same body,
369    /// so one atomic replace keeps the pair consistent.
370    fn commit_meta(&self, meta: &CatalogCacheMeta) -> Result<(), CatalogError> {
371        let directory = self.cache.as_ref().map_err(Clone::clone)?;
372        let lock = directory
373            .lock_within(CATALOG_LOCK_FILE, DEFAULT_LOCK_BUDGET)
374            .map_err(CatalogError::from_store)?;
375        recover_cache(&lock)?;
376        let installed = lock
377            .read(CATALOG_META_FILE, MAX_META_BYTES)
378            .map_err(CatalogError::from_store)?
379            .map(|bytes| parse_cache_meta(&bytes))
380            .transpose()?;
381        if installed.is_none_or(|installed| {
382            installed.body_revision != meta.body_revision
383                || installed.byte_length != meta.byte_length
384        }) {
385            return Err(CatalogError::new(
386                "catalog_cache_changed",
387                "catalog cache body changed before its metadata could be updated",
388            ));
389        }
390        let meta_bytes = serde_json::to_vec_pretty(meta).map_err(|_| {
391            CatalogError::new(
392                "cache_metadata_write_failed",
393                "catalog cache metadata could not be encoded",
394            )
395        })?;
396        lock.atomic_replace(CATALOG_META_FILE, &meta_bytes)
397            .map_err(CatalogError::from_store)
398    }
399
400    fn commit_cache(&self, body: &[u8], meta: &CatalogCacheMeta) -> Result<(), CatalogError> {
401        let directory = self.cache.as_ref().map_err(Clone::clone)?;
402        let lock = directory
403            .lock_within(CATALOG_LOCK_FILE, DEFAULT_LOCK_BUDGET)
404            .map_err(CatalogError::from_store)?;
405        recover_cache(&lock)?;
406        let meta_bytes = serde_json::to_vec_pretty(meta).map_err(|_| {
407            CatalogError::new(
408                "cache_metadata_write_failed",
409                "catalog cache metadata could not be encoded",
410            )
411        })?;
412        let body_revision = revision(body);
413        validate_meta(meta, body.len(), &body_revision)?;
414        self.commit_checkpoint(CacheCommitPhase::BeforeNextBody)?;
415        lock.atomic_replace(BODY_NEXT_FILE, body)
416            .map_err(CatalogError::from_store)?;
417        self.commit_checkpoint(CacheCommitPhase::AfterNextBody)?;
418        lock.atomic_replace(META_NEXT_FILE, &meta_bytes)
419            .map_err(CatalogError::from_store)?;
420        self.commit_checkpoint(CacheCommitPhase::AfterNextMetadata)?;
421        validate_staged_pair(&lock, body, &meta_bytes)?;
422
423        let previous = read_fixed_pair(&lock)?;
424        let prepared = CacheJournalRecord::prepared(
425            previous.is_some(),
426            body_revision.as_str().to_owned(),
427            digest_bytes(&meta_bytes),
428        );
429        append_journal_record(&lock, &prepared)?;
430        self.commit_checkpoint(CacheCommitPhase::AfterPrepared)?;
431
432        if let Some(previous) = &previous {
433            lock.atomic_replace(BODY_BACKUP_FILE, &previous.body)
434                .map_err(CatalogError::from_store)?;
435            self.commit_checkpoint(CacheCommitPhase::AfterBodyBackup)?;
436            lock.atomic_replace(META_BACKUP_FILE, &previous.meta)
437                .map_err(CatalogError::from_store)?;
438        }
439        self.commit_checkpoint(CacheCommitPhase::AfterMetadataBackup)?;
440        append_journal_record(
441            &lock,
442            &CacheJournalRecord::phase(CacheJournalPhase::Installing),
443        )?;
444        self.commit_checkpoint(CacheCommitPhase::AfterInstalling)?;
445
446        lock.atomic_replace(CATALOG_BODY_FILE, body)
447            .map_err(CatalogError::from_store)?;
448        self.commit_checkpoint(CacheCommitPhase::AfterBodyInstall)?;
449        lock.atomic_replace(CATALOG_META_FILE, &meta_bytes)
450            .map_err(CatalogError::from_store)?;
451        self.commit_checkpoint(CacheCommitPhase::AfterMetadataInstall)?;
452        validate_fixed_pair(&lock, &prepared)?;
453        self.commit_checkpoint(CacheCommitPhase::AfterValidation)?;
454
455        append_journal_record(
456            &lock,
457            &CacheJournalRecord::phase(CacheJournalPhase::Committed),
458        )?;
459        self.commit_checkpoint(CacheCommitPhase::AfterCommitted)?;
460        cleanup_transaction_files(&lock)?;
461        self.commit_checkpoint(CacheCommitPhase::AfterCleanup)?;
462        lock.clear_journal().map_err(CatalogError::from_store)?;
463        Ok(())
464    }
465
466    #[cfg(all(test, unix))]
467    fn fail_commit_at(&self, phase: CacheCommitPhase) {
468        *self.commit_failure.lock().expect("commit failure lock") = Some(phase);
469    }
470
471    #[cfg(all(test, unix))]
472    fn commit_checkpoint(&self, phase: CacheCommitPhase) -> Result<(), CatalogError> {
473        let mut failure = self.commit_failure.lock().expect("commit failure lock");
474        if failure.as_ref() == Some(&phase) {
475            *failure = None;
476            Err(CatalogError::new(
477                "cache_commit_injected_failure",
478                "catalog cache commit failure was injected",
479            ))
480        } else {
481            Ok(())
482        }
483    }
484
485    #[cfg(not(all(test, unix)))]
486    fn commit_checkpoint(&self, _phase: CacheCommitPhase) -> Result<(), CatalogError> {
487        Ok(())
488    }
489}
490
491#[derive(Clone, Copy, Debug, Eq, PartialEq)]
492enum CacheCommitPhase {
493    BeforeNextBody,
494    AfterNextBody,
495    AfterNextMetadata,
496    AfterPrepared,
497    AfterBodyBackup,
498    AfterMetadataBackup,
499    AfterInstalling,
500    AfterBodyInstall,
501    AfterMetadataInstall,
502    AfterValidation,
503    AfterCommitted,
504    AfterCleanup,
505}
506
507#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
508#[serde(rename_all = "snake_case")]
509enum CacheJournalPhase {
510    Prepared,
511    Installing,
512    Committed,
513}
514
515#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
516#[serde(deny_unknown_fields)]
517struct CacheJournalRecord {
518    schema_version: u32,
519    phase: CacheJournalPhase,
520    had_previous: Option<bool>,
521    body_revision: Option<String>,
522    metadata_digest: Option<String>,
523}
524
525impl CacheJournalRecord {
526    fn prepared(had_previous: bool, body_revision: String, metadata_digest: String) -> Self {
527        Self {
528            schema_version: CATALOG_CACHE_SCHEMA_VERSION,
529            phase: CacheJournalPhase::Prepared,
530            had_previous: Some(had_previous),
531            body_revision: Some(body_revision),
532            metadata_digest: Some(metadata_digest),
533        }
534    }
535
536    const fn phase(phase: CacheJournalPhase) -> Self {
537        Self {
538            schema_version: CATALOG_CACHE_SCHEMA_VERSION,
539            phase,
540            had_previous: None,
541            body_revision: None,
542            metadata_digest: None,
543        }
544    }
545
546    fn valid(&self) -> bool {
547        if self.schema_version != CATALOG_CACHE_SCHEMA_VERSION {
548            return false;
549        }
550        match self.phase {
551            CacheJournalPhase::Prepared => {
552                self.had_previous.is_some()
553                    && self
554                        .body_revision
555                        .as_deref()
556                        .is_some_and(valid_sha256_revision)
557                    && self
558                        .metadata_digest
559                        .as_deref()
560                        .is_some_and(valid_sha256_revision)
561            }
562            CacheJournalPhase::Installing | CacheJournalPhase::Committed => {
563                self.had_previous.is_none()
564                    && self.body_revision.is_none()
565                    && self.metadata_digest.is_none()
566            }
567        }
568    }
569}
570
571fn append_journal_record(
572    lock: &SecureDirectoryLock<'_>,
573    record: &CacheJournalRecord,
574) -> Result<(), CatalogError> {
575    let mut bytes = serde_json::to_vec(record).map_err(|_| {
576        CatalogError::new(
577            "cache_journal_write_failed",
578            "catalog cache transaction journal could not be encoded",
579        )
580    })?;
581    bytes.push(b'\n');
582    lock.append_journal(&bytes, MAX_JOURNAL_BYTES)
583        .map_err(CatalogError::from_store)
584}
585
586fn recover_cache(lock: &SecureDirectoryLock<'_>) -> Result<(), CatalogError> {
587    let bytes = lock
588        .read_journal(MAX_JOURNAL_BYTES)
589        .map_err(CatalogError::from_store)?;
590    let records = valid_journal_prefix(&bytes);
591    let Some(prepared) = records.first() else {
592        lock.clear_journal().map_err(CatalogError::from_store)?;
593        cleanup_transaction_files(lock)?;
594        return Ok(());
595    };
596    let committed = records
597        .last()
598        .is_some_and(|record| record.phase == CacheJournalPhase::Committed);
599    let installing = records
600        .iter()
601        .any(|record| record.phase == CacheJournalPhase::Installing);
602    if committed && fixed_pair_matches(lock, prepared)? {
603        lock.clear_journal().map_err(CatalogError::from_store)?;
604        cleanup_transaction_files(lock)?;
605        return Ok(());
606    }
607    if installing {
608        rollback_transaction(lock, prepared.had_previous == Some(true))?;
609    }
610    lock.clear_journal().map_err(CatalogError::from_store)?;
611    cleanup_transaction_files(lock)?;
612    Ok(())
613}
614
615fn valid_journal_prefix(bytes: &[u8]) -> Vec<CacheJournalRecord> {
616    let mut records = Vec::new();
617    for line in bytes.split_inclusive(|byte| *byte == b'\n') {
618        let Some(payload) = line.strip_suffix(b"\n") else {
619            break;
620        };
621        let Ok(record) = serde_json::from_slice::<CacheJournalRecord>(payload) else {
622            break;
623        };
624        if !record.valid() {
625            break;
626        }
627        let expected = match records.len() {
628            0 => CacheJournalPhase::Prepared,
629            1 => CacheJournalPhase::Installing,
630            2 => CacheJournalPhase::Committed,
631            _ => break,
632        };
633        if record.phase != expected {
634            break;
635        }
636        records.push(record);
637    }
638    records
639}
640
641fn rollback_transaction(
642    lock: &SecureDirectoryLock<'_>,
643    had_previous: bool,
644) -> Result<(), CatalogError> {
645    if had_previous {
646        let body = lock
647            .read(BODY_BACKUP_FILE, CATALOG_MAX_BYTES as u64)
648            .map_err(CatalogError::from_store)?
649            .ok_or_else(|| {
650                CatalogError::new(
651                    "cache_recovery_failed",
652                    "catalog cache body backup is missing",
653                )
654            })?;
655        let meta = lock
656            .read(META_BACKUP_FILE, MAX_META_BYTES)
657            .map_err(CatalogError::from_store)?
658            .ok_or_else(|| {
659                CatalogError::new(
660                    "cache_recovery_failed",
661                    "catalog cache metadata backup is missing",
662                )
663            })?;
664        validate_pair_bytes(&body, &meta)?;
665        lock.atomic_replace(CATALOG_BODY_FILE, &body)
666            .map_err(CatalogError::from_store)?;
667        lock.atomic_replace(CATALOG_META_FILE, &meta)
668            .map_err(CatalogError::from_store)?;
669    } else {
670        lock.remove(CATALOG_BODY_FILE)
671            .map_err(CatalogError::from_store)?;
672        lock.remove(CATALOG_META_FILE)
673            .map_err(CatalogError::from_store)?;
674    }
675    Ok(())
676}
677
678fn cleanup_transaction_files(lock: &SecureDirectoryLock<'_>) -> Result<(), CatalogError> {
679    for name in [
680        BODY_NEXT_FILE,
681        META_NEXT_FILE,
682        BODY_BACKUP_FILE,
683        META_BACKUP_FILE,
684    ] {
685        lock.remove(name).map_err(CatalogError::from_store)?;
686    }
687    Ok(())
688}
689
690struct CachePair {
691    body: Vec<u8>,
692    meta: Vec<u8>,
693}
694
695fn read_fixed_pair(lock: &SecureDirectoryLock<'_>) -> Result<Option<CachePair>, CatalogError> {
696    let body = lock
697        .read(CATALOG_BODY_FILE, CATALOG_MAX_BYTES as u64)
698        .map_err(CatalogError::from_store)?;
699    let meta = lock
700        .read(CATALOG_META_FILE, MAX_META_BYTES)
701        .map_err(CatalogError::from_store)?;
702    match (body, meta) {
703        (Some(body), Some(meta)) if validate_pair_bytes(&body, &meta).is_ok() => {
704            Ok(Some(CachePair { body, meta }))
705        }
706        (None, None) => Ok(None),
707        _ => {
708            lock.remove(CATALOG_BODY_FILE)
709                .map_err(CatalogError::from_store)?;
710            lock.remove(CATALOG_META_FILE)
711                .map_err(CatalogError::from_store)?;
712            Ok(None)
713        }
714    }
715}
716
717fn validate_staged_pair(
718    lock: &SecureDirectoryLock<'_>,
719    expected_body: &[u8],
720    expected_meta: &[u8],
721) -> Result<(), CatalogError> {
722    let body = lock
723        .read(BODY_NEXT_FILE, CATALOG_MAX_BYTES as u64)
724        .map_err(CatalogError::from_store)?
725        .ok_or_else(|| {
726            CatalogError::new(
727                "cache_transaction_validation_failed",
728                "catalog cache staged body is missing",
729            )
730        })?;
731    let meta = lock
732        .read(META_NEXT_FILE, MAX_META_BYTES)
733        .map_err(CatalogError::from_store)?
734        .ok_or_else(|| {
735            CatalogError::new(
736                "cache_transaction_validation_failed",
737                "catalog cache staged metadata is missing",
738            )
739        })?;
740    if body != expected_body || meta != expected_meta {
741        return Err(CatalogError::new(
742            "cache_transaction_validation_failed",
743            "catalog cache staged pair changed before commit",
744        ));
745    }
746    validate_pair_bytes(&body, &meta).map(|_| ())
747}
748
749fn validate_fixed_pair(
750    lock: &SecureDirectoryLock<'_>,
751    prepared: &CacheJournalRecord,
752) -> Result<(), CatalogError> {
753    if fixed_pair_matches(lock, prepared)? {
754        Ok(())
755    } else {
756        Err(CatalogError::new(
757            "cache_transaction_validation_failed",
758            "catalog cache fixed pair changed before commit",
759        ))
760    }
761}
762
763fn fixed_pair_matches(
764    lock: &SecureDirectoryLock<'_>,
765    prepared: &CacheJournalRecord,
766) -> Result<bool, CatalogError> {
767    let Some(body) = lock
768        .read(CATALOG_BODY_FILE, CATALOG_MAX_BYTES as u64)
769        .map_err(CatalogError::from_store)?
770    else {
771        return Ok(false);
772    };
773    let Some(meta) = lock
774        .read(CATALOG_META_FILE, MAX_META_BYTES)
775        .map_err(CatalogError::from_store)?
776    else {
777        return Ok(false);
778    };
779    Ok(
780        validate_pair_bytes(&body, &meta).is_ok_and(|body_revision| {
781            prepared.body_revision.as_deref() == Some(body_revision.as_str())
782        }) && prepared.metadata_digest.as_deref() == Some(digest_bytes(&meta).as_str()),
783    )
784}
785
786/// Validates a body and its metadata as a pair, returning the body's revision
787/// (hashed once, while parsing) so callers need not hash it again.
788fn validate_pair_bytes(body: &[u8], meta_bytes: &[u8]) -> Result<CatalogRevision, CatalogError> {
789    let meta = parse_cache_meta(meta_bytes)?;
790    let parsed = parse_catalog(body)?;
791    validate_meta(&meta, body.len(), &parsed.revision)?;
792    validate_parsed_meta(&meta, &parsed)?;
793    Ok(parsed.revision)
794}
795
796fn digest_bytes(bytes: &[u8]) -> String {
797    format!("sha256:{:x}", Sha256::digest(bytes))
798}
799
800fn valid_sha256_revision(value: &str) -> bool {
801    value.len() == 71
802        && value.starts_with("sha256:")
803        && value[7..].bytes().all(|byte| byte.is_ascii_hexdigit())
804}
805
806enum NetworkResult {
807    NotModified,
808    Body {
809        bytes: Vec<u8>,
810        etag: Option<String>,
811    },
812    Streaming {
813        body: super::CatalogBodyStream,
814        capacity: usize,
815        etag: Option<String>,
816    },
817}
818
819impl<T: CatalogTransport> CatalogManager<T> {
820    async fn finish_stream(result: NetworkResult) -> Result<NetworkResult, CatalogError> {
821        let NetworkResult::Streaming {
822            mut body,
823            capacity,
824            etag,
825        } = result
826        else {
827            return Ok(result);
828        };
829        let mut bytes = Vec::with_capacity(capacity.min(CATALOG_MAX_BYTES));
830        while let Some(chunk) = body.next().await {
831            let chunk = chunk.map_err(CatalogError::from_transport)?;
832            if bytes.len().saturating_add(chunk.len()) > CATALOG_MAX_BYTES {
833                return Err(CatalogError::new(
834                    "catalog_body_too_large",
835                    "catalog response exceeds the byte limit",
836                ));
837            }
838            bytes.extend_from_slice(&chunk);
839        }
840        Ok(NetworkResult::Body { bytes, etag })
841    }
842}
843
844struct ValidatedCache {
845    parsed: super::ParsedCatalog,
846    meta: CatalogCacheMeta,
847}
848
849fn metadata_for(
850    parsed: &super::ParsedCatalog,
851    source: CatalogSource,
852    stale: bool,
853    validated_at: Timestamp,
854    checked_at: Timestamp,
855    etag: Option<String>,
856    last_error: Option<CatalogSafeErrorMeta>,
857) -> CatalogCacheMeta {
858    CatalogCacheMeta {
859        schema_version: CATALOG_CACHE_SCHEMA_VERSION,
860        url: MODELS_DEV_CATALOG_URL.to_owned(),
861        body_revision: parsed.revision.as_str().to_owned(),
862        etag,
863        byte_length: parsed.body.len() as u64,
864        validated_at,
865        last_checked_at: checked_at,
866        selected_source: source,
867        stale,
868        last_error,
869    }
870}
871
872/// Checks metadata against its body, given the body's length and its
873/// already-computed revision.
874fn validate_meta(
875    meta: &CatalogCacheMeta,
876    body_len: usize,
877    body_revision: &CatalogRevision,
878) -> Result<(), CatalogError> {
879    if meta.schema_version != CATALOG_CACHE_SCHEMA_VERSION
880        || meta.url != MODELS_DEV_CATALOG_URL
881        || meta.byte_length != body_len as u64
882        || meta.body_revision != body_revision.as_str()
883        || meta.etag.clone().map(validate_etag).transpose()?.as_deref() != meta.etag.as_deref()
884    {
885        return Err(CatalogError::new(
886            "invalid_catalog_cache_metadata",
887            "catalog cache metadata does not match its body",
888        ));
889    }
890    Ok(())
891}
892
893fn validate_parsed_meta(
894    meta: &CatalogCacheMeta,
895    parsed: &super::ParsedCatalog,
896) -> Result<(), CatalogError> {
897    let _ = parsed;
898    let error_valid = meta.last_error.as_ref().is_none_or(|error| {
899        !error.code.is_empty()
900            && error.code.len() <= 128
901            && error.code.bytes().all(|byte| {
902                byte.is_ascii_lowercase()
903                    || byte.is_ascii_digit()
904                    || matches!(byte, b'.' | b'_' | b'-')
905            })
906            && error.safe_message.len() <= MAX_SAFE_MESSAGE_BYTES
907            && !error.safe_message.chars().any(char::is_control)
908    });
909    if !error_valid {
910        Err(CatalogError::new(
911            "invalid_catalog_cache_metadata",
912            "catalog cache metadata quarantine state is invalid",
913        ))
914    } else {
915        Ok(())
916    }
917}
918
919fn revision(body: &[u8]) -> CatalogRevision {
920    CatalogRevision::new(format!("sha256:{:x}", Sha256::digest(body)))
921        .expect("SHA-256 revision is valid")
922}
923
924fn snapshot_from_parsed(
925    parsed: super::ParsedCatalog,
926    source: CatalogSource,
927    validated_at: Timestamp,
928    checked_at: Timestamp,
929    etag: Option<String>,
930    availability: CatalogAvailability,
931    last_error: Option<CatalogSafeErrorMeta>,
932) -> CatalogSnapshot {
933    CatalogSnapshot {
934        revision: parsed.revision,
935        source,
936        state: CatalogRuntimeState {
937            availability,
938            age: age_state(validated_at, checked_at),
939            last_error,
940        },
941        validated_at,
942        last_checked_at: checked_at,
943        etag,
944        providers: parsed.providers,
945        canonical_models: parsed.canonical_models,
946        quarantine: parsed.quarantine,
947    }
948}
949
950fn age_state(validated_at: Timestamp, now: Timestamp) -> CatalogAgeState {
951    let age = now.as_second().saturating_sub(validated_at.as_second());
952    if age >= THIRTY_DAYS_SECONDS {
953        CatalogAgeState::OlderThanThirtyDays
954    } else if age >= SEVEN_DAYS_SECONDS {
955        CatalogAgeState::OlderThanSevenDays
956    } else {
957        CatalogAgeState::Current
958    }
959}
960
961fn validate_etag(value: String) -> Result<String, CatalogError> {
962    let opaque = value
963        .strip_prefix("W/\"")
964        .or_else(|| value.strip_prefix('"'))
965        .and_then(|value| value.strip_suffix('"'));
966    if value.is_empty()
967        || value.len() > MAX_ETAG_BYTES
968        || value.chars().any(char::is_control)
969        || value.contains(['\r', '\n'])
970        || http::HeaderValue::from_bytes(value.as_bytes()).is_err()
971        || opaque.is_none_or(|opaque| opaque.contains('"'))
972    {
973        Err(CatalogError::new(
974            "invalid_catalog_etag",
975            "catalog ETag is invalid",
976        ))
977    } else {
978        Ok(value)
979    }
980}
981
982/// Stable body-free catalog error.
983#[derive(Clone, Debug, Error, Eq, PartialEq)]
984#[error("{safe_message}")]
985pub struct CatalogError {
986    code: String,
987    safe_message: String,
988}
989
990impl CatalogError {
991    pub(crate) fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
992        let mut safe_message = message.into();
993        if safe_message.len() > MAX_SAFE_MESSAGE_BYTES {
994            let mut end = MAX_SAFE_MESSAGE_BYTES;
995            while !safe_message.is_char_boundary(end) {
996                end -= 1;
997            }
998            safe_message.truncate(end);
999        }
1000        safe_message.retain(|character| !character.is_control());
1001        Self {
1002            code: code.into(),
1003            safe_message,
1004        }
1005    }
1006
1007    #[must_use]
1008    pub fn code(&self) -> &str {
1009        &self.code
1010    }
1011
1012    #[must_use]
1013    pub fn safe_message(&self) -> &str {
1014        &self.safe_message
1015    }
1016
1017    #[must_use]
1018    pub fn safe_meta(&self, occurred_at: Timestamp) -> CatalogSafeErrorMeta {
1019        CatalogSafeErrorMeta {
1020            code: self.code.clone(),
1021            safe_message: self.safe_message.clone(),
1022            occurred_at,
1023        }
1024    }
1025
1026    fn from_store(error: SecureStoreError) -> Self {
1027        let code = match error {
1028            SecureStoreError::HomeUnavailable => "catalog_cache_home_unavailable",
1029            SecureStoreError::UnsafePath => "catalog_cache_invalid_path",
1030            SecureStoreError::TooLarge => "catalog_cache_too_large",
1031            SecureStoreError::LockContention { .. } => "catalog_cache_lock_contention",
1032            SecureStoreError::Io(_) => "catalog_cache_io_failed",
1033        };
1034        Self::new(code, "catalog cache could not be used")
1035    }
1036
1037    fn from_transport(error: CatalogTransportError) -> Self {
1038        let code = match error {
1039            CatalogTransportError::ClientBuild => "catalog_transport_client_build_failed",
1040            CatalogTransportError::InvalidRequest => "catalog_transport_invalid_request",
1041            CatalogTransportError::InvalidEtag => "invalid_catalog_etag",
1042            CatalogTransportError::InvalidHeaders => "catalog_response_headers_invalid",
1043            CatalogTransportError::RequestFailed => "catalog_network_failed",
1044            CatalogTransportError::BodyReadFailed => "catalog_body_read_failed",
1045            CatalogTransportError::BodyTooLarge => "catalog_body_too_large",
1046        };
1047        Self::new(code, error.to_string())
1048    }
1049}
1050
1051#[cfg(all(test, unix))]
1052mod tests {
1053    use std::{collections::VecDeque, fs, os::unix::fs::PermissionsExt as _, sync::Mutex};
1054
1055    use super::*;
1056    use crate::catalog::{CatalogRequest, CatalogTransportFuture, CatalogTransportResponse};
1057
1058    struct TestTransport {
1059        responses: Mutex<VecDeque<Result<CatalogTransportResponse, CatalogTransportError>>>,
1060    }
1061
1062    impl CatalogTransport for TestTransport {
1063        fn fetch(&self, _request: CatalogRequest) -> CatalogTransportFuture<'_> {
1064            let response = self
1065                .responses
1066                .lock()
1067                .expect("transport responses")
1068                .pop_front()
1069                .expect("scripted response");
1070            Box::pin(async move { response })
1071        }
1072    }
1073
1074    fn body(name: &str) -> Vec<u8> {
1075        serde_json::to_vec(&serde_json::json!({
1076            "providers": {
1077                "test": {
1078                    "id": "test",
1079                    "env": ["TEST_API_KEY"],
1080                    "npm": "test-npm",
1081                    "name": "Test",
1082                    "doc": "https://example.invalid",
1083                    "models": {
1084                        "model": {
1085                            "id": "model",
1086                            "name": name,
1087                            "description": "test",
1088                            "attachment": false,
1089                            "reasoning": false,
1090                            "tool_call": false,
1091                            "open_weights": false,
1092                            "release_date": "2026-08-01",
1093                            "last_updated": "2026-08-05",
1094                            "modalities": {"input": ["text"], "output": ["text"]},
1095                            "limit": {"context": 4096, "output": 1024}
1096                        }
1097                    }
1098                }
1099            },
1100            "models": {
1101                "model": {
1102                    "id": "model",
1103                    "name": name,
1104                    "description": "test",
1105                    "attachment": false,
1106                    "reasoning": false,
1107                    "tool_call": false,
1108                    "open_weights": false,
1109                    "release_date": "2026-08-01",
1110                    "last_updated": "2026-08-05",
1111                    "modalities": {"input": ["text"], "output": ["text"]},
1112                    "limit": {"context": 4096, "output": 1024}
1113                }
1114            }
1115        }))
1116        .expect("catalog fixture")
1117    }
1118
1119    fn time() -> Timestamp {
1120        "2026-08-05T00:00:00Z".parse().expect("timestamp")
1121    }
1122
1123    #[tokio::test]
1124    async fn every_fixed_pair_commit_failure_recovers_one_complete_transaction() {
1125        for phase in [
1126            CacheCommitPhase::BeforeNextBody,
1127            CacheCommitPhase::AfterNextBody,
1128            CacheCommitPhase::AfterNextMetadata,
1129            CacheCommitPhase::AfterPrepared,
1130            CacheCommitPhase::AfterBodyBackup,
1131            CacheCommitPhase::AfterMetadataBackup,
1132            CacheCommitPhase::AfterInstalling,
1133            CacheCommitPhase::AfterBodyInstall,
1134            CacheCommitPhase::AfterMetadataInstall,
1135            CacheCommitPhase::AfterValidation,
1136            CacheCommitPhase::AfterCommitted,
1137            CacheCommitPhase::AfterCleanup,
1138        ] {
1139            let temporary = tempfile::tempdir().expect("temporary directory");
1140            fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o700))
1141                .expect("private temporary directory");
1142            let directory = SecureDirectory::open_in(temporary.path(), "catalog")
1143                .expect("secure catalog directory");
1144            let first_body = body("first");
1145            let second_body = body("second");
1146            let transport = TestTransport {
1147                responses: Mutex::new(VecDeque::from([
1148                    Ok(CatalogTransportResponse::from_bytes(200, first_body)),
1149                    Ok(CatalogTransportResponse::from_bytes(200, second_body)),
1150                    Err(CatalogTransportError::RequestFailed),
1151                ])),
1152            };
1153            let manager = CatalogManager::new(transport, directory);
1154            let first = manager.refresh_at(time()).await.expect("first transaction");
1155            manager.fail_commit_at(phase);
1156            let second = manager.refresh_at(time()).await.expect("network selection");
1157            assert_eq!(
1158                second
1159                    .state
1160                    .last_error
1161                    .as_ref()
1162                    .map(|error| error.code.as_str()),
1163                Some("cache_commit_injected_failure"),
1164                "phase {phase:?}"
1165            );
1166            let recovered = manager.refresh_at(time()).await.expect("cache recovery");
1167            assert_eq!(recovered.source, CatalogSource::Cache, "phase {phase:?}");
1168            let expected = if matches!(
1169                phase,
1170                CacheCommitPhase::AfterCommitted | CacheCommitPhase::AfterCleanup
1171            ) {
1172                &second.revision
1173            } else {
1174                &first.revision
1175            };
1176            assert_eq!(&recovered.revision, expected, "phase {phase:?}");
1177            let entries = fs::read_dir(temporary.path().join("catalog"))
1178                .expect("cache directory")
1179                .map(|entry| {
1180                    entry
1181                        .expect("cache entry")
1182                        .file_name()
1183                        .into_string()
1184                        .expect("UTF-8 cache name")
1185                })
1186                .collect::<std::collections::BTreeSet<_>>();
1187            assert_eq!(
1188                entries,
1189                [CATALOG_BODY_FILE, CATALOG_META_FILE, CATALOG_LOCK_FILE]
1190                    .into_iter()
1191                    .map(str::to_owned)
1192                    .collect(),
1193                "phase {phase:?}"
1194            );
1195        }
1196    }
1197}