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
33pub 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 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 #[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 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 pub fn load_cached(&self) -> Result<CatalogSnapshot, CatalogError> {
81 self.load_cached_at(Timestamp::now())
82 }
83
84 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 pub async fn refresh(&self) -> Result<CatalogSnapshot, CatalogError> {
120 self.refresh_at(Timestamp::now()).await
121 }
122
123 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 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 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
786fn 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
872fn 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#[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}