use super::{
NnsAuthenticatedRegistryArchive, NnsCertifiedSubnetCatalogAuthority,
NnsCertifiedSubnetCatalogProjectionRequest, NnsRegistrySubnetCatalogProjectionError,
project_nns_certified_subnet_catalog,
};
use crate::{
cache_file::{
CacheFileError, RefreshLockRequest, create_managed_parent_directory, open_managed_file,
with_refresh_lock, write_managed_file_atomically,
},
hex::hex_bytes,
subnet_catalog::{MAINNET_NETWORK, RawSubnetCatalog},
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
io::{self, Read, Write},
path::{Path, PathBuf},
};
use thiserror::Error as ThisError;
const CERTIFIED_CATALOG_FILE_NAME: &str = "catalog.json";
const CERTIFIED_CATALOG_LOCK_FILE_NAME: &str = "refresh.lock";
pub const NNS_CERTIFIED_SUBNET_CATALOG_CACHE_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NnsCertifiedSubnetCatalogCacheLocation {
pub cache_root: PathBuf,
pub cache_directory: PathBuf,
pub maximum_cache_bytes: u64,
}
impl NnsCertifiedSubnetCatalogCacheLocation {
#[must_use]
pub fn new(
cache_root: impl Into<PathBuf>,
cache_directory: impl Into<PathBuf>,
maximum_cache_bytes: u64,
) -> Self {
Self {
cache_root: cache_root.into(),
cache_directory: cache_directory.into(),
maximum_cache_bytes,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NnsCertifiedSubnetCatalogCachePublicationRequest {
pub location: NnsCertifiedSubnetCatalogCacheLocation,
pub lock_stale_after_seconds: u64,
}
impl NnsCertifiedSubnetCatalogCachePublicationRequest {
#[must_use]
pub const fn new(
location: NnsCertifiedSubnetCatalogCacheLocation,
lock_stale_after_seconds: u64,
) -> Self {
Self {
location,
lock_stale_after_seconds,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NnsCertifiedSubnetCatalogCacheEnvelope {
pub schema_version: u32,
pub archive_manifest_sha256: String,
pub catalog: RawSubnetCatalog,
}
#[derive(Serialize)]
struct CertifiedCatalogCacheEnvelopeRef<'a> {
schema_version: u32,
archive_manifest_sha256: String,
catalog: &'a RawSubnetCatalog,
}
#[derive(Debug, Eq, PartialEq)]
pub struct NnsCertifiedSubnetCatalogCacheAuthority<'a> {
authority: NnsCertifiedSubnetCatalogAuthority<'a>,
}
impl<'a> NnsCertifiedSubnetCatalogCacheAuthority<'a> {
#[must_use]
pub const fn authority(&self) -> &NnsCertifiedSubnetCatalogAuthority<'a> {
&self.authority
}
}
#[derive(Debug, ThisError)]
pub enum NnsCertifiedSubnetCatalogCacheError {
#[error(transparent)]
Projection(#[from] NnsRegistrySubnetCatalogProjectionError),
#[error("certified Subnet Catalog cache filesystem operation failed: {source}")]
FileOperation {
#[source]
source: CacheFileError,
},
#[error("certified Subnet Catalog cache is missing at {path}")]
MissingCache {
path: PathBuf,
},
#[error(
"certified Subnet Catalog cache at {path} exceeds its byte limit: actual={actual}, maximum={maximum}"
)]
CacheLimitExceeded {
path: PathBuf,
actual: u64,
maximum: u64,
},
#[error("certified Subnet Catalog cache at {path} is invalid JSON: {source}")]
InvalidJson {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("certified Subnet Catalog cache at {path} is not canonical compact JSON")]
NonCanonicalEncoding {
path: PathBuf,
},
#[error(
"unsupported certified Subnet Catalog cache schema {found}; supported schema is {supported}"
)]
UnsupportedSchemaVersion {
found: u32,
supported: u32,
},
#[error(
"certified Subnet Catalog cache field {field} does not match the supplied authenticated Registry archive projection"
)]
ArchiveBindingMismatch {
field: &'static str,
},
#[error("certified Subnet Catalog cache serialization failed: {source}")]
Serialization {
#[source]
source: serde_json::Error,
},
#[error("certified Subnet Catalog cache byte accounting overflowed")]
Accounting,
}
#[must_use]
pub fn nns_certified_subnet_catalog_cache_path(cache_directory: &Path) -> PathBuf {
cache_directory.join(CERTIFIED_CATALOG_FILE_NAME)
}
#[must_use]
pub fn nns_certified_subnet_catalog_cache_refresh_lock_path(cache_directory: &Path) -> PathBuf {
cache_directory.join(CERTIFIED_CATALOG_LOCK_FILE_NAME)
}
pub fn publish_nns_certified_subnet_catalog_cache<'a>(
archive: &'a NnsAuthenticatedRegistryArchive,
projection_request: &NnsCertifiedSubnetCatalogProjectionRequest,
request: &NnsCertifiedSubnetCatalogCachePublicationRequest,
) -> Result<NnsCertifiedSubnetCatalogCacheAuthority<'a>, NnsCertifiedSubnetCatalogCacheError> {
let authority = project_nns_certified_subnet_catalog(archive, projection_request)?;
let envelope = cache_envelope(&authority)?;
let cache_path = nns_certified_subnet_catalog_cache_path(&request.location.cache_directory);
let lock_path =
nns_certified_subnet_catalog_cache_refresh_lock_path(&request.location.cache_directory);
let encoded_length = canonical_serialized_len(&envelope)?;
enforce_cache_limit(
&cache_path,
encoded_length,
request.location.maximum_cache_bytes,
)?;
create_managed_parent_directory(&request.location.cache_root, &cache_path)
.map_err(file_operation)?;
with_refresh_lock(
RefreshLockRequest {
cache_root: &request.location.cache_root,
lock_path: &lock_path,
target_path: &cache_path,
network: MAINNET_NETWORK,
now_unix_secs: projection_request.validation.now_unix_secs,
lock_stale_after_seconds: request.lock_stale_after_seconds,
},
file_operation,
|| {
write_managed_file_atomically(&request.location.cache_root, &cache_path, |file| {
serde_json::to_writer(file, &envelope).map_err(json_io_error)
})
.map_err(file_operation)
},
)?;
Ok(NnsCertifiedSubnetCatalogCacheAuthority { authority })
}
pub fn load_nns_certified_subnet_catalog_cache<'a>(
archive: &'a NnsAuthenticatedRegistryArchive,
projection_request: &NnsCertifiedSubnetCatalogProjectionRequest,
location: &NnsCertifiedSubnetCatalogCacheLocation,
) -> Result<NnsCertifiedSubnetCatalogCacheAuthority<'a>, NnsCertifiedSubnetCatalogCacheError> {
let cache_path = nns_certified_subnet_catalog_cache_path(&location.cache_directory);
let bytes = read_bounded_cache(location, &cache_path)?.ok_or_else(|| {
NnsCertifiedSubnetCatalogCacheError::MissingCache {
path: cache_path.clone(),
}
})?;
let envelope: NnsCertifiedSubnetCatalogCacheEnvelope =
serde_json::from_slice(&bytes).map_err(|source| {
NnsCertifiedSubnetCatalogCacheError::InvalidJson {
path: cache_path.clone(),
source,
}
})?;
if envelope.schema_version != NNS_CERTIFIED_SUBNET_CATALOG_CACHE_SCHEMA_VERSION {
return Err(
NnsCertifiedSubnetCatalogCacheError::UnsupportedSchemaVersion {
found: envelope.schema_version,
supported: NNS_CERTIFIED_SUBNET_CATALOG_CACHE_SCHEMA_VERSION,
},
);
}
if !is_canonical_encoding(&envelope, &bytes)? {
return Err(NnsCertifiedSubnetCatalogCacheError::NonCanonicalEncoding { path: cache_path });
}
let authority = project_nns_certified_subnet_catalog(archive, projection_request)?;
let expected = cache_envelope(&authority)?;
if let Some(field) = first_envelope_mismatch(&envelope, &expected) {
return Err(NnsCertifiedSubnetCatalogCacheError::ArchiveBindingMismatch { field });
}
Ok(NnsCertifiedSubnetCatalogCacheAuthority { authority })
}
fn first_envelope_mismatch(
cached: &NnsCertifiedSubnetCatalogCacheEnvelope,
expected: &CertifiedCatalogCacheEnvelopeRef<'_>,
) -> Option<&'static str> {
if cached.schema_version != expected.schema_version {
return Some("schema_version");
}
if cached.archive_manifest_sha256 != expected.archive_manifest_sha256 {
return Some("archive_manifest_sha256");
}
if &cached.catalog != expected.catalog {
return Some("catalog");
}
None
}
fn cache_envelope<'a>(
authority: &'a NnsCertifiedSubnetCatalogAuthority<'_>,
) -> Result<CertifiedCatalogCacheEnvelopeRef<'a>, NnsCertifiedSubnetCatalogCacheError> {
let manifest = authority.archive().manifest();
let catalog = authority.catalog().raw();
Ok(CertifiedCatalogCacheEnvelopeRef {
schema_version: NNS_CERTIFIED_SUBNET_CATALOG_CACHE_SCHEMA_VERSION,
archive_manifest_sha256: canonical_sha256(manifest)?,
catalog,
})
}
fn canonical_sha256(value: &impl Serialize) -> Result<String, NnsCertifiedSubnetCatalogCacheError> {
let mut digest = Sha256::new();
serde_json::to_writer(&mut digest, value)
.map_err(|source| NnsCertifiedSubnetCatalogCacheError::Serialization { source })?;
Ok(hex_bytes(&digest.finalize()))
}
fn canonical_serialized_len(
value: &impl Serialize,
) -> Result<u64, NnsCertifiedSubnetCatalogCacheError> {
let mut writer = CountingWriter::default();
serde_json::to_writer(&mut writer, value)
.map_err(|source| NnsCertifiedSubnetCatalogCacheError::Serialization { source })?;
Ok(writer.bytes)
}
fn is_canonical_encoding(
value: &impl Serialize,
bytes: &[u8],
) -> Result<bool, NnsCertifiedSubnetCatalogCacheError> {
let mut writer = MatchingWriter::new(bytes);
serde_json::to_writer(&mut writer, value)
.map_err(|source| NnsCertifiedSubnetCatalogCacheError::Serialization { source })?;
Ok(writer.is_complete_match())
}
fn read_bounded_cache(
location: &NnsCertifiedSubnetCatalogCacheLocation,
path: &Path,
) -> Result<Option<Vec<u8>>, NnsCertifiedSubnetCatalogCacheError> {
let Some(mut file) = open_managed_file(&location.cache_root, path).map_err(file_operation)?
else {
return Ok(None);
};
let metadata_length = file
.metadata()
.map_err(|source| {
file_operation(CacheFileError::OpenManagedPath {
root: location.cache_root.clone(),
path: path.to_path_buf(),
source,
})
})?
.len();
enforce_cache_limit(path, metadata_length, location.maximum_cache_bytes)?;
let capacity = usize::try_from(metadata_length)
.map_err(|_| NnsCertifiedSubnetCatalogCacheError::Accounting)?;
let mut bytes = Vec::with_capacity(capacity);
Read::by_ref(&mut file)
.take(location.maximum_cache_bytes.saturating_add(1))
.read_to_end(&mut bytes)
.map_err(|source| {
file_operation(CacheFileError::OpenManagedPath {
root: location.cache_root.clone(),
path: path.to_path_buf(),
source,
})
})?;
let actual =
u64::try_from(bytes.len()).map_err(|_| NnsCertifiedSubnetCatalogCacheError::Accounting)?;
enforce_cache_limit(path, actual, location.maximum_cache_bytes)?;
Ok(Some(bytes))
}
fn enforce_cache_limit(
path: &Path,
actual: u64,
maximum: u64,
) -> Result<(), NnsCertifiedSubnetCatalogCacheError> {
if actual > maximum {
return Err(NnsCertifiedSubnetCatalogCacheError::CacheLimitExceeded {
path: path.to_path_buf(),
actual,
maximum,
});
}
Ok(())
}
const fn file_operation(source: CacheFileError) -> NnsCertifiedSubnetCatalogCacheError {
NnsCertifiedSubnetCatalogCacheError::FileOperation { source }
}
fn json_io_error(source: serde_json::Error) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, source)
}
#[derive(Default)]
struct CountingWriter {
bytes: u64,
}
impl Write for CountingWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let length = u64::try_from(bytes.len())
.map_err(|_| io::Error::other("serialized byte count overflowed"))?;
self.bytes = self
.bytes
.checked_add(length)
.ok_or_else(|| io::Error::other("serialized byte count overflowed"))?;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
struct MatchingWriter<'a> {
expected: &'a [u8],
position: usize,
matches: bool,
}
impl<'a> MatchingWriter<'a> {
const fn new(expected: &'a [u8]) -> Self {
Self {
expected,
position: 0,
matches: true,
}
}
const fn is_complete_match(&self) -> bool {
self.matches && self.position == self.expected.len()
}
}
impl Write for MatchingWriter<'_> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
let end = self
.position
.checked_add(bytes.len())
.ok_or_else(|| io::Error::other("serialized byte count overflowed"))?;
if self.expected.get(self.position..end) != Some(bytes) {
self.matches = false;
}
self.position = end;
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}