use super::{
CatalogSourceSelection, SubnetCatalogHostError, SubnetCatalogRefreshRequest,
SubnetCatalogSource, error::enforce_mainnet_network, refresh_subnet_catalog_with_source_async,
subnet_catalog_path,
};
use crate::{
cache_file::read_managed_text,
nns::LiveNnsSource,
runtime::block_on_current_thread,
subnet_catalog::{
CatalogAssurance, CatalogValidationContext, DEFAULT_CATALOG_MAX_FUTURE_SKEW_SECONDS,
DEFAULT_REFRESH_LOCK_STALE_SECONDS, MAINNET_REGISTRY_CANISTER_ID, ValidatedSubnetCatalog,
catalog_stale_status, parse_catalog_json,
},
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubnetCatalogCacheRequest {
pub cache_root: PathBuf,
pub network: String,
}
impl SubnetCatalogCacheRequest {
#[must_use]
pub fn new(cache_root: impl Into<PathBuf>, network: impl Into<String>) -> Self {
Self {
cache_root: cache_root.into(),
network: network.into(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum CatalogReadPolicy {
CacheOnly,
RefreshMissing {
source: CatalogSourceSelection,
},
RefreshMissingOrInvalid {
source: CatalogSourceSelection,
},
RefreshMissingInvalidOrOlderThan {
source: CatalogSourceSelection,
max_age_seconds: u64,
},
ForceRefresh {
source: CatalogSourceSelection,
},
}
impl CatalogReadPolicy {
const fn source(&self) -> Option<&CatalogSourceSelection> {
match self {
Self::CacheOnly => None,
Self::RefreshMissing { source }
| Self::RefreshMissingOrInvalid { source }
| Self::RefreshMissingInvalidOrOlderThan { source, .. }
| Self::ForceRefresh { source } => Some(source),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SubnetCatalogLoadRequest {
pub cache: SubnetCatalogCacheRequest,
pub now_unix_secs: u64,
pub max_future_skew_seconds: u64,
pub minimum_assurance: CatalogAssurance,
pub policy: CatalogReadPolicy,
}
impl SubnetCatalogLoadRequest {
#[must_use]
pub const fn cache_only(cache: SubnetCatalogCacheRequest, now_unix_secs: u64) -> Self {
Self {
cache,
now_unix_secs,
max_future_skew_seconds: DEFAULT_CATALOG_MAX_FUTURE_SKEW_SECONDS,
minimum_assurance: CatalogAssurance::UncertifiedQuery,
policy: CatalogReadPolicy::CacheOnly,
}
}
#[must_use]
pub const fn refresh_missing_or_invalid(
cache: SubnetCatalogCacheRequest,
source: CatalogSourceSelection,
now_unix_secs: u64,
) -> Self {
Self {
cache,
now_unix_secs,
max_future_skew_seconds: DEFAULT_CATALOG_MAX_FUTURE_SKEW_SECONDS,
minimum_assurance: CatalogAssurance::UncertifiedQuery,
policy: CatalogReadPolicy::RefreshMissingOrInvalid { source },
}
}
#[must_use]
pub const fn refresh_missing_invalid_or_older_than(
cache: SubnetCatalogCacheRequest,
source: CatalogSourceSelection,
now_unix_secs: u64,
max_age_seconds: u64,
) -> Self {
Self {
cache,
now_unix_secs,
max_future_skew_seconds: DEFAULT_CATALOG_MAX_FUTURE_SKEW_SECONDS,
minimum_assurance: CatalogAssurance::UncertifiedQuery,
policy: CatalogReadPolicy::RefreshMissingInvalidOrOlderThan {
source,
max_age_seconds,
},
}
}
#[must_use]
pub const fn with_max_future_skew_seconds(mut self, seconds: u64) -> Self {
self.max_future_skew_seconds = seconds;
self
}
#[must_use]
pub const fn with_minimum_assurance(mut self, minimum: CatalogAssurance) -> Self {
self.minimum_assurance = minimum;
self
}
#[must_use]
pub fn with_policy(mut self, policy: CatalogReadPolicy) -> Self {
self.policy = policy;
self
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheDisposition {
CacheHit,
RefreshedMissing,
RefreshedInvalid,
RefreshedStale,
ForcedRefresh,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct CatalogAuthorityEvidence {
pub registry_version: u64,
pub catalog_digest: String,
pub assurance: CatalogAssurance,
pub source_endpoints: Vec<String>,
pub cache_disposition: CacheDisposition,
}
impl CacheDisposition {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::CacheHit => "cache_hit",
Self::RefreshedMissing => "refreshed_missing",
Self::RefreshedInvalid => "refreshed_invalid",
Self::RefreshedStale => "refreshed_stale",
Self::ForcedRefresh => "forced_refresh",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CatalogLoadOutcome {
pub path: PathBuf,
pub catalog: ValidatedSubnetCatalog,
pub disposition: CacheDisposition,
}
impl CatalogLoadOutcome {
#[must_use]
pub fn authority_evidence(&self) -> CatalogAuthorityEvidence {
let provenance = self.catalog.provenance();
CatalogAuthorityEvidence {
registry_version: provenance.registry_version,
catalog_digest: self.catalog.raw().catalog_digest.clone(),
assurance: provenance.assurance,
source_endpoints: provenance.source_endpoints.clone(),
cache_disposition: self.disposition,
}
}
}
pub fn load_cached_subnet_catalog(
request: &SubnetCatalogLoadRequest,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
if request.policy != CatalogReadPolicy::CacheOnly {
return Err(SubnetCatalogHostError::InvalidReadPolicy {
reason: "load_cached_subnet_catalog requires CatalogReadPolicy::CacheOnly".to_string(),
});
}
load_cached_with_disposition(request, CacheDisposition::CacheHit)
}
pub fn load_subnet_catalog(
request: &SubnetCatalogLoadRequest,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
block_on_current_thread(load_subnet_catalog_async(request))?
}
pub fn load_subnet_catalog_with_source(
request: &SubnetCatalogLoadRequest,
source: &dyn SubnetCatalogSource,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
block_on_current_thread(load_subnet_catalog_with_source_async(request, source))?
}
pub async fn load_subnet_catalog_async(
request: &SubnetCatalogLoadRequest,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
load_subnet_catalog_with_source_async(request, &LiveNnsSource).await
}
pub async fn load_subnet_catalog_with_source_async(
request: &SubnetCatalogLoadRequest,
source: &dyn SubnetCatalogSource,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
enforce_mainnet_network(&request.cache.network)?;
match &request.policy {
CatalogReadPolicy::CacheOnly => {
load_cached_with_disposition(request, CacheDisposition::CacheHit)
}
CatalogReadPolicy::ForceRefresh { .. } => {
refresh_then_load(request, source, CacheDisposition::ForcedRefresh).await
}
policy => match load_cached_with_disposition(request, CacheDisposition::CacheHit) {
Ok(cached) => {
let max_age_seconds = match policy {
CatalogReadPolicy::RefreshMissingInvalidOrOlderThan {
max_age_seconds, ..
} => Some(*max_age_seconds),
_ => None,
};
if max_age_seconds.is_some_and(|max_age_seconds| {
catalog_stale_status(
cached.catalog.raw(),
request.now_unix_secs,
max_age_seconds,
)
.catalog_stale
}) {
refresh_then_load(request, source, CacheDisposition::RefreshedStale).await
} else {
Ok(cached)
}
}
Err(SubnetCatalogHostError::MissingCatalog { .. }) => {
refresh_then_load(request, source, CacheDisposition::RefreshedMissing).await
}
Err(SubnetCatalogHostError::Catalog(_))
if matches!(
policy,
CatalogReadPolicy::RefreshMissingOrInvalid { .. }
| CatalogReadPolicy::RefreshMissingInvalidOrOlderThan { .. }
) =>
{
refresh_then_load(request, source, CacheDisposition::RefreshedInvalid).await
}
Err(error) => Err(error),
},
}
}
fn load_cached_with_disposition(
request: &SubnetCatalogLoadRequest,
disposition: CacheDisposition,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
enforce_mainnet_network(&request.cache.network)?;
let path = subnet_catalog_path(&request.cache.cache_root, &request.cache.network);
let Some(data) = read_managed_text(&request.cache.cache_root, &path)
.map_err(super::error::subnet_cache_error)?
else {
return Err(SubnetCatalogHostError::MissingCatalog { path });
};
let raw = parse_catalog_json(&data)?;
let validation = CatalogValidationContext::new(
&request.cache.network,
MAINNET_REGISTRY_CANISTER_ID,
request.now_unix_secs,
request.max_future_skew_seconds,
);
let catalog = ValidatedSubnetCatalog::try_from_raw(raw, &validation)?;
enforce_minimum_assurance(catalog.provenance().assurance, request.minimum_assurance)?;
Ok(CatalogLoadOutcome {
path,
catalog,
disposition,
})
}
async fn refresh_then_load(
request: &SubnetCatalogLoadRequest,
source: &dyn SubnetCatalogSource,
disposition: CacheDisposition,
) -> Result<CatalogLoadOutcome, SubnetCatalogHostError> {
let source_selection =
request
.policy
.source()
.ok_or_else(|| SubnetCatalogHostError::InvalidReadPolicy {
reason: "refresh policy is missing its source selection".to_string(),
})?;
enforce_minimum_assurance(source_selection.assurance(), request.minimum_assurance)?;
let refresh_request = SubnetCatalogRefreshRequest::new(
request.cache.clone(),
source_selection.clone(),
request.now_unix_secs,
DEFAULT_REFRESH_LOCK_STALE_SECONDS,
)
.with_max_future_skew_seconds(request.max_future_skew_seconds);
refresh_subnet_catalog_with_source_async(&refresh_request, source).await?;
load_cached_with_disposition(request, disposition)
}
const fn enforce_minimum_assurance(
actual: CatalogAssurance,
required: CatalogAssurance,
) -> Result<(), SubnetCatalogHostError> {
if actual.satisfies(required) {
Ok(())
} else {
Err(SubnetCatalogHostError::InsufficientAssurance { required, actual })
}
}