1pub mod policy;
8pub mod storage;
9pub mod trust;
10
11use crate::atp::identity::IdentityError;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::time::{Duration, SystemTime};
16
17#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct CacheKey {
20 pub manifest_hash: String,
22 pub content_hash: String,
24 pub grant_scope: Option<String>,
26}
27
28impl CacheKey {
29 #[must_use]
31 pub fn new(manifest_hash: String, content_hash: String, grant_scope: Option<String>) -> Self {
32 Self {
33 manifest_hash,
34 content_hash,
35 grant_scope,
36 }
37 }
38
39 #[must_use]
41 pub fn as_index_key(&self) -> String {
42 let mut index_key = String::new();
43 index_key.push_str("v1|");
44 push_index_key_part(&mut index_key, 'm', &self.manifest_hash);
45 push_index_key_part(&mut index_key, 'c', &self.content_hash);
46 match &self.grant_scope {
47 Some(scope) => push_index_key_part(&mut index_key, 's', scope),
48 None => index_key.push('n'),
49 }
50 index_key
51 }
52
53 #[must_use]
55 pub fn declares_encrypted_content(&self) -> bool {
56 self.grant_scope
57 .as_deref()
58 .is_some_and(scope_declares_encrypted_content)
59 }
60}
61
62fn push_index_key_part(index_key: &mut String, label: char, value: &str) {
63 index_key.push(label);
64 index_key.push(':');
65 index_key.push_str(&value.len().to_string());
66 index_key.push(':');
67 index_key.push_str(value);
68 index_key.push('|');
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CacheEntry {
74 pub key: CacheKey,
76 pub size_bytes: u64,
78 pub created_at: SystemTime,
80 pub last_accessed: SystemTime,
82 pub access_count: u64,
84 pub ttl: Duration,
86 pub encrypted: bool,
88 pub storage_location: StorageLocation,
90 pub verification: VerificationMetadata,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub enum StorageLocation {
97 File(PathBuf),
99 Memory(String),
101 External(String),
103}
104
105fn scope_declares_encrypted_content(scope: &str) -> bool {
106 scope
107 .split(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '-'))
108 .any(|token| {
109 matches!(
110 token.to_ascii_lowercase().as_str(),
111 "encrypted" | "ciphertext" | "e2e" | "end-to-end" | "sealed"
112 )
113 })
114}
115
116fn content_has_encrypted_envelope(content: &[u8]) -> bool {
117 let Ok(value) = serde_json::from_slice::<serde_json::Value>(content) else {
118 return false;
119 };
120 let Some(object) = value.as_object() else {
121 return false;
122 };
123
124 let has_ciphertext = object.contains_key("ciphertext") || object.contains_key("encrypted_data");
125 let has_nonce = object.contains_key("nonce") || object.contains_key("iv");
126 let has_tag = object.contains_key("tag") || object.contains_key("auth_tag");
127 let has_algorithm = object
128 .get("algorithm")
129 .or_else(|| object.get("cipher"))
130 .and_then(serde_json::Value::as_str)
131 .is_some_and(|algorithm| {
132 let algorithm = algorithm.to_ascii_lowercase();
133 algorithm.contains("aes") || algorithm.contains("chacha") || algorithm.contains("gcm")
134 });
135
136 has_ciphertext && has_nonce && has_tag && has_algorithm
137}
138
139fn derive_cache_entry_encryption_status(key: &CacheKey, content: &[u8]) -> bool {
140 key.declares_encrypted_content() || content_has_encrypted_envelope(content)
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct VerificationMetadata {
146 pub content_verified: bool,
148 pub manifest_verified: bool,
150 pub proof_location: Option<String>,
152 pub verified_at: Option<SystemTime>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct CacheConfig {
159 pub max_size_bytes: u64,
161 pub max_entries: usize,
163 pub default_ttl: Duration,
165 pub eviction_policy: EvictionPolicy,
167 pub allow_plaintext_shared: bool,
169 pub storage_root: PathBuf,
171 pub compression_enabled: bool,
173}
174
175impl Default for CacheConfig {
176 fn default() -> Self {
177 Self {
178 max_size_bytes: 1_073_741_824, max_entries: 10_000,
180 default_ttl: Duration::from_secs(24 * 60 * 60), eviction_policy: EvictionPolicy::LeastRecentlyUsed,
182 allow_plaintext_shared: false, storage_root: PathBuf::from(".cache"),
184 compression_enabled: true,
185 }
186 }
187}
188
189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "snake_case")]
192pub enum EvictionPolicy {
193 LeastRecentlyUsed,
195 LeastFrequentlyUsed,
197 ShortestTtl,
199 LargestFirst,
201 Hybrid,
203}
204
205#[derive(Debug, Default, Clone, Serialize, Deserialize)]
207pub struct CacheMetrics {
208 pub hits: u64,
210 pub misses: u64,
212 pub evictions: u64,
214 pub verification_failures: u64,
216 pub total_bytes: u64,
218 pub entry_count: usize,
220 pub hit_ratio: f64,
222}
223
224impl CacheMetrics {
225 pub fn update_hit_ratio(&mut self) {
227 let total = self.hits + self.misses;
228 self.hit_ratio = if total > 0 {
229 self.hits as f64 / total as f64
230 } else {
231 0.0
232 };
233 }
234
235 pub fn record_hit(&mut self) {
237 self.hits += 1;
238 self.update_hit_ratio();
239 }
240
241 pub fn record_miss(&mut self) {
243 self.misses += 1;
244 self.update_hit_ratio();
245 }
246
247 pub fn record_eviction(&mut self, size_bytes: u64) {
249 self.evictions += 1;
250 self.total_bytes = self.total_bytes.saturating_sub(size_bytes);
251 self.entry_count = self.entry_count.saturating_sub(1);
252 }
253}
254
255#[derive(Debug)]
257pub struct AtpCache {
258 config: CacheConfig,
260 entries: HashMap<String, CacheEntry>,
262 memory_storage: HashMap<String, Vec<u8>>,
264 access_order: Vec<String>,
266 metrics: CacheMetrics,
268 trust_policy: trust::TrustPolicy,
270}
271
272impl AtpCache {
273 pub fn new(config: CacheConfig) -> Self {
275 Self {
276 config,
277 entries: HashMap::new(),
278 memory_storage: HashMap::new(),
279 access_order: Vec::new(),
280 metrics: CacheMetrics::default(),
281 trust_policy: trust::TrustPolicy::default(),
282 }
283 }
284
285 pub fn get(&mut self, key: &CacheKey) -> Result<Option<Vec<u8>>, CacheError> {
287 let index_key = key.as_index_key();
288
289 let entry = if let Some(entry) = self.entries.get(&index_key) {
291 let elapsed = entry.created_at.elapsed().unwrap_or(Duration::MAX);
293 if elapsed > entry.ttl {
294 if let Some(entry) = self.entries.remove(&index_key) {
295 self.remove_expired_entry(&index_key, entry);
296 }
297 self.metrics.record_miss();
298 return Ok(None);
299 }
300 entry
301 } else {
302 self.metrics.record_miss();
303 return Ok(None);
304 };
305
306 self.trust_policy.check_access(key)?;
308
309 let storage_location = entry.storage_location.clone();
311
312 let content = match &storage_location {
314 StorageLocation::File(path) => match std::fs::read(path) {
315 Ok(content) => Some(content),
316 Err(_) => {
317 self.remove(key)?;
319 self.metrics.record_miss();
320 None
321 }
322 },
323 StorageLocation::Memory(memory_key) => match self.memory_storage.get(memory_key) {
324 Some(content) => Some(content.clone()),
325 None => {
326 self.remove(key)?;
327 self.metrics.record_miss();
328 None
329 }
330 },
331 StorageLocation::External(location) => {
332 let content = retrieve_external_cache_location(location)?;
333 Some(content)
334 }
335 };
336
337 if let Some(content) = content {
338 self.update_access(&index_key);
339 self.metrics.record_hit();
340 Ok(Some(content))
341 } else {
342 Ok(None)
343 }
344 }
345
346 pub fn put(&mut self, key: CacheKey, content: &[u8]) -> Result<(), CacheError> {
348 let actual_hash = self.compute_content_hash(content);
350 if actual_hash != key.content_hash {
351 return Err(CacheError::VerificationFailed(
352 "Content hash mismatch".to_string(),
353 ));
354 }
355
356 let encrypted = derive_cache_entry_encryption_status(&key, content);
357
358 self.trust_policy.check_storage(&key, encrypted)?;
360
361 let index_key = key.as_index_key();
362 let size_bytes = content.len() as u64;
363 let replaced_entry = self.entries.get(&index_key).cloned();
364
365 if replaced_entry.is_none() {
367 self.ensure_space_for(size_bytes)?;
368 }
369
370 let storage_location = if size_bytes < 64 * 1024 {
372 let memory_key = index_key.clone();
374 self.memory_storage
375 .insert(memory_key.clone(), content.to_vec());
376 StorageLocation::Memory(memory_key)
377 } else {
378 let filename = format!("{}.cache", actual_hash);
380 let path = self.config.storage_root.join(filename);
381
382 if let Some(parent) = path.parent() {
384 std::fs::create_dir_all(parent).map_err(|e| CacheError::Storage(e.to_string()))?;
385 }
386
387 std::fs::write(&path, content).map_err(|e| CacheError::Storage(e.to_string()))?;
389
390 StorageLocation::File(path)
391 };
392
393 let now = SystemTime::now();
395 let entry = CacheEntry {
396 key: key.clone(),
397 size_bytes,
398 created_at: now,
399 last_accessed: now,
400 access_count: 0,
401 ttl: self.config.default_ttl,
402 encrypted,
403 storage_location: storage_location.clone(),
404 verification: VerificationMetadata {
405 content_verified: true,
406 manifest_verified: false,
407 proof_location: None,
408 verified_at: Some(now),
409 },
410 };
411
412 self.entries.insert(index_key.clone(), entry);
414 self.access_order.retain(|k| k != &index_key);
415 self.access_order.push(index_key);
416
417 if let Some(replaced_entry) = replaced_entry {
419 if replaced_entry.storage_location != storage_location {
420 self.remove_storage_location(&replaced_entry.storage_location);
421 }
422 self.metrics.total_bytes = self
423 .metrics
424 .total_bytes
425 .saturating_sub(replaced_entry.size_bytes)
426 .saturating_add(size_bytes);
427 } else {
428 self.metrics.total_bytes = self.metrics.total_bytes.saturating_add(size_bytes);
429 self.metrics.entry_count = self.metrics.entry_count.saturating_add(1);
430 }
431
432 Ok(())
433 }
434
435 pub fn remove(&mut self, key: &CacheKey) -> Result<(), CacheError> {
437 let index_key = key.as_index_key();
438
439 if let Some(entry) = self.entries.remove(&index_key) {
440 self.access_order.retain(|k| k != &index_key);
442
443 self.remove_storage_location(&entry.storage_location);
445
446 self.metrics.record_eviction(entry.size_bytes);
448 }
449
450 Ok(())
451 }
452
453 #[must_use]
455 pub const fn metrics(&self) -> &CacheMetrics {
456 &self.metrics
457 }
458
459 fn compute_content_hash(&self, content: &[u8]) -> String {
461 use sha2::{Digest, Sha256};
462
463 let mut hasher = Sha256::new();
464 hasher.update(content);
465 hex::encode(hasher.finalize())
466 }
467
468 fn update_access(&mut self, index_key: &str) {
470 self.access_order.retain(|k| k != index_key);
472 self.access_order.push(index_key.to_string());
473
474 if let Some(entry) = self.entries.get_mut(index_key) {
476 entry.last_accessed = SystemTime::now();
477 entry.access_count = entry.access_count.saturating_add(1);
478 }
479 }
480
481 fn ensure_space_for(&mut self, size_bytes: u64) -> Result<(), CacheError> {
483 while (self.metrics.total_bytes.saturating_add(size_bytes) > self.config.max_size_bytes)
485 || (self.metrics.entry_count >= self.config.max_entries)
486 {
487 if self.access_order.is_empty() {
488 return Err(CacheError::InsufficientSpace);
489 }
490
491 let to_evict = self.access_order.remove(0);
493 if let Some(entry) = self.entries.remove(&to_evict) {
494 self.remove_storage_location(&entry.storage_location);
496
497 self.metrics.record_eviction(entry.size_bytes);
498 }
499 }
500
501 Ok(())
502 }
503
504 fn remove_expired_entry(&mut self, index_key: &str, entry: CacheEntry) {
505 self.remove_storage_location(&entry.storage_location);
506 self.access_order.retain(|k| k != index_key);
507 self.metrics.record_eviction(entry.size_bytes);
508 }
509
510 fn remove_storage_location(&mut self, storage_location: &StorageLocation) {
511 match storage_location {
512 StorageLocation::File(path) => {
513 let _ = std::fs::remove_file(path);
514 }
515 StorageLocation::Memory(memory_key) => {
516 self.memory_storage.remove(memory_key);
517 }
518 StorageLocation::External(_) => {}
519 }
520 }
521}
522
523fn retrieve_external_cache_location(location: &str) -> Result<Vec<u8>, CacheError> {
524 if let Some(path) = location.strip_prefix("file://") {
525 return std::fs::read(path).map_err(|error| {
526 CacheError::External(format!(
527 "failed to read external file cache location: {error}"
528 ))
529 });
530 }
531 let path = PathBuf::from(location);
532 if path.is_absolute() {
533 return std::fs::read(path).map_err(|error| {
534 CacheError::External(format!("failed to read external cache path: {error}"))
535 });
536 }
537 Err(CacheError::External(format!(
538 "external cache location requires a configured backend: {location}"
539 )))
540}
541
542#[derive(Debug, thiserror::Error)]
544pub enum CacheError {
545 #[error("Storage error: {0}")]
546 Storage(String),
547
548 #[error("Verification failed: {0}")]
549 VerificationFailed(String),
550
551 #[error("Trust policy violation: {0}")]
552 TrustViolation(String),
553
554 #[error("External cache error: {0}")]
555 External(String),
556
557 #[error("Insufficient cache space")]
558 InsufficientSpace,
559
560 #[error("Identity error: {0}")]
561 Identity(#[from] IdentityError),
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use std::time::Duration;
568
569 #[test]
570 fn cache_key_index_key_generation() {
571 let key = CacheKey::new(
572 "manifest123".to_string(),
573 "content456".to_string(),
574 Some("scope789".to_string()),
575 );
576 assert_eq!(
577 key.as_index_key(),
578 "v1|m:11:manifest123|c:10:content456|s:8:scope789|"
579 );
580
581 let key_no_scope = CacheKey::new("manifest123".to_string(), "content456".to_string(), None);
582 assert_eq!(
583 key_no_scope.as_index_key(),
584 "v1|m:11:manifest123|c:10:content456|n"
585 );
586 }
587
588 #[test]
589 fn cache_key_index_key_is_not_delimiter_collision_prone() {
590 let scoped = CacheKey::new("a".to_string(), "b".to_string(), Some("c".to_string()));
591 let unscoped_with_delimiter = CacheKey::new("a".to_string(), "b:c".to_string(), None);
592
593 assert_ne!(
594 scoped.as_index_key(),
595 unscoped_with_delimiter.as_index_key()
596 );
597 }
598
599 #[test]
600 fn cache_metrics_hit_ratio_calculation() {
601 let mut metrics = CacheMetrics::default();
602
603 metrics.record_hit();
604 metrics.record_hit();
605 metrics.record_miss();
606
607 assert_eq!(metrics.hits, 2);
608 assert_eq!(metrics.misses, 1);
609 assert!((metrics.hit_ratio - 0.6667).abs() < 0.001);
610 }
611
612 #[test]
613 fn cache_config_defaults() {
614 let config = CacheConfig::default();
615 assert_eq!(config.max_size_bytes, 1_073_741_824);
616 assert!(!config.allow_plaintext_shared);
617 assert_eq!(config.eviction_policy, EvictionPolicy::LeastRecentlyUsed);
618 }
619
620 #[test]
621 fn cache_basic_put_get() {
622 let mut cache = AtpCache::new(CacheConfig::default());
623 let key = CacheKey::new(
624 "manifest123".to_string(),
625 "d2d2d2d2d2d2d2d2".to_string(), None,
627 );
628 let content = b"test content";
629
630 let result = cache.put(key.clone(), content);
632 assert!(result.is_err()); }
634
635 fn sha256_hex(content: &[u8]) -> String {
636 use sha2::{Digest, Sha256};
637
638 hex::encode(Sha256::digest(content))
639 }
640
641 #[test]
642 fn cache_put_existing_key_does_not_duplicate_metrics_or_lru_entries() {
643 let mut config = CacheConfig::default();
644 config.max_entries = 1;
645 let mut cache = AtpCache::new(config);
646 let content = b"stable cache content";
647 let key = CacheKey::new("manifest123".to_string(), sha256_hex(content), None);
648
649 cache.put(key.clone(), content).unwrap();
650 cache.put(key.clone(), content).unwrap();
651
652 assert_eq!(cache.metrics().entry_count, 1);
653 assert_eq!(cache.metrics().total_bytes, content.len() as u64);
654 assert_eq!(cache.access_order.len(), 1);
655 assert_eq!(cache.get(&key).unwrap().as_deref(), Some(&content[..]));
656 }
657
658 #[test]
659 fn cache_entry_encryption_status_is_derived_from_scope_or_envelope() {
660 let encrypted_key = CacheKey::new(
661 "manifest123".to_string(),
662 "content456".to_string(),
663 Some("team:engineering:encrypted".to_string()),
664 );
665 assert!(derive_cache_entry_encryption_status(
666 &encrypted_key,
667 b"plaintext"
668 ));
669
670 let envelope_key = CacheKey::new("manifest123".to_string(), "content456".to_string(), None);
671 let envelope = br#"{
672 "algorithm": "aes-256-gcm",
673 "nonce": "000000000000000000000000",
674 "ciphertext": "deadbeef",
675 "tag": "cafebabe"
676 }"#;
677 assert!(derive_cache_entry_encryption_status(
678 &envelope_key,
679 envelope
680 ));
681
682 let plaintext_key =
683 CacheKey::new("manifest123".to_string(), "content456".to_string(), None);
684 assert!(!derive_cache_entry_encryption_status(
685 &plaintext_key,
686 b"plaintext"
687 ));
688 }
689
690 #[test]
691 fn cache_ttl_toctou_fix() {
692 let mut cache = AtpCache::new(CacheConfig::default());
693
694 let key = CacheKey::new(
696 "manifest123".to_string(),
697 "d2d2d2d2d2d2d2d2".to_string(), None,
699 );
700
701 let content = b"test content";
703
704 let expired_entry = CacheEntry {
706 key: key.clone(),
707 size_bytes: content.len() as u64,
708 created_at: SystemTime::now() - Duration::from_secs(3600), last_accessed: SystemTime::now(),
710 access_count: 1,
711 ttl: Duration::from_secs(60), encrypted: true,
713 storage_location: StorageLocation::Memory("test".to_string()),
714 verification: VerificationMetadata {
715 content_verified: true,
716 manifest_verified: true,
717 proof_location: None,
718 verified_at: Some(SystemTime::now()),
719 },
720 };
721
722 cache.entries.insert(key.as_index_key(), expired_entry);
724 cache.access_order.push(key.as_index_key());
725 cache
726 .memory_storage
727 .insert("test".to_string(), content.to_vec());
728 cache.metrics.total_bytes = content.len() as u64;
729 cache.metrics.entry_count = 1;
730 assert_eq!(cache.entries.len(), 1);
731
732 let result = cache.get(&key);
734 assert!(result.is_ok());
735 assert!(result.unwrap().is_none()); assert_eq!(cache.entries.len(), 0);
739 assert_eq!(cache.access_order.len(), 0);
740 assert_eq!(cache.metrics().total_bytes, 0);
741 assert_eq!(cache.metrics().entry_count, 0);
742 }
743
744 #[test]
745 fn cache_eviction_on_size_limit() {
746 let mut config = CacheConfig::default();
747 config.max_size_bytes = 100; let cache = AtpCache::new(config);
750 assert_eq!(cache.metrics().total_bytes, 0);
751 assert_eq!(cache.metrics().entry_count, 0);
752 }
753
754 #[test]
757 fn golden_cache_config_default_serialization() {
758 let config = CacheConfig::default();
759 assert_eq!(
760 serde_json::to_value(&config).unwrap(),
761 serde_json::json!({
762 "max_size_bytes": 1_073_741_824_u64,
763 "max_entries": 10_000,
764 "default_ttl": {
765 "secs": 86_400,
766 "nanos": 0,
767 },
768 "eviction_policy": "least_recently_used",
769 "allow_plaintext_shared": false,
770 "storage_root": ".cache",
771 "compression_enabled": true,
772 })
773 );
774 }
775
776 #[test]
777 fn golden_cache_config_custom_serialization() {
778 use std::path::PathBuf;
779
780 let config = CacheConfig {
781 max_size_bytes: 512 * 1024 * 1024, max_entries: 5_000,
783 default_ttl: Duration::from_secs(12 * 60 * 60), eviction_policy: EvictionPolicy::Hybrid,
785 allow_plaintext_shared: true,
786 storage_root: PathBuf::from("/var/cache/atp"),
787 compression_enabled: false,
788 };
789 assert_eq!(
790 serde_json::to_value(&config).unwrap(),
791 serde_json::json!({
792 "max_size_bytes": 536_870_912_u64,
793 "max_entries": 5_000,
794 "default_ttl": {
795 "secs": 43_200,
796 "nanos": 0,
797 },
798 "eviction_policy": "hybrid",
799 "allow_plaintext_shared": true,
800 "storage_root": "/var/cache/atp",
801 "compression_enabled": false,
802 })
803 );
804 }
805
806 #[test]
807 fn golden_cache_key_serialization() {
808 let key_with_scope = CacheKey::new(
810 "sha256:a1b2c3d4e5f6g7h8".to_string(),
811 "sha256:1234567890abcdef".to_string(),
812 Some("team:engineering".to_string()),
813 );
814 assert_eq!(
815 serde_json::to_value(&key_with_scope).unwrap(),
816 serde_json::json!({
817 "manifest_hash": "sha256:a1b2c3d4e5f6g7h8",
818 "content_hash": "sha256:1234567890abcdef",
819 "grant_scope": "team:engineering",
820 })
821 );
822
823 let key_no_scope = CacheKey::new(
825 "sha256:fedcba0987654321".to_string(),
826 "sha256:abcdef1234567890".to_string(),
827 None,
828 );
829 assert_eq!(
830 serde_json::to_value(&key_no_scope).unwrap(),
831 serde_json::json!({
832 "manifest_hash": "sha256:fedcba0987654321",
833 "content_hash": "sha256:abcdef1234567890",
834 "grant_scope": null,
835 })
836 );
837 }
838
839 #[test]
840 fn golden_eviction_policy_serialization() {
841 let policies = vec![
842 EvictionPolicy::LeastRecentlyUsed,
843 EvictionPolicy::LeastFrequentlyUsed,
844 EvictionPolicy::ShortestTtl,
845 EvictionPolicy::LargestFirst,
846 EvictionPolicy::Hybrid,
847 ];
848
849 assert_eq!(
850 serde_json::to_value(&policies).unwrap(),
851 serde_json::json!([
852 "least_recently_used",
853 "least_frequently_used",
854 "shortest_ttl",
855 "largest_first",
856 "hybrid",
857 ])
858 );
859 }
860
861 #[test]
862 fn golden_cache_metrics_serialization() {
863 let mut metrics = CacheMetrics::default();
864 metrics.hits = 1500;
865 metrics.misses = 300;
866 metrics.evictions = 25;
867 metrics.verification_failures = 2;
868 metrics.total_bytes = 1024 * 1024; metrics.entry_count = 150;
870 metrics.update_hit_ratio();
871
872 assert_eq!(
873 serde_json::to_value(&metrics).unwrap(),
874 serde_json::json!({
875 "hits": 1_500,
876 "misses": 300,
877 "evictions": 25,
878 "verification_failures": 2,
879 "total_bytes": 1_048_576_u64,
880 "entry_count": 150,
881 "hit_ratio": 0.8333333333333334,
882 })
883 );
884 }
885}