Skip to main content

ic_query/subnet_catalog/
mod.rs

1mod model;
2mod resolver;
3
4#[cfg(test)]
5use crate::duration::parse_duration_seconds;
6use crate::ic_registry::{
7    DEFAULT_MAINNET_ENDPOINT, MainnetRegistryFetchRequest, RegistryFetchError,
8    fetch_mainnet_subnet_catalog,
9};
10use crate::{
11    cache_file::{
12        CacheFileError, RefreshLockRequest, acquire_refresh_lock, announce_cache_refresh,
13        create_directory, write_text_atomically, write_text_output,
14    },
15    nns::render::yes_no,
16    table::{ColumnAlign, render_table},
17};
18use candid::Principal;
19pub use model::{
20    ClassificationSource, GeographicScope, RoutingRange, SubnetCatalog, SubnetInfo, SubnetKind,
21    SubnetSpecialization,
22};
23pub use resolver::{ResolveAs, ResolvedSubnet, ResolvedSubnetSubject};
24use serde::{Deserialize, Serialize};
25use std::{
26    fs, io,
27    path::{Path, PathBuf},
28};
29use thiserror::Error as ThisError;
30
31pub const CATALOG_SCHEMA_VERSION: u32 = 1;
32pub const MAINNET_NETWORK: &str = "ic";
33pub const MAINNET_REGISTRY_CANISTER_ID: &str = "rwlgt-iiaaa-aaaaa-aaaaa-cai";
34pub(crate) const DEFAULT_STALE_AFTER_SECONDS: u64 = 7 * 24 * 60 * 60;
35pub(crate) const DEFAULT_REFRESH_LOCK_STALE_SECONDS: u64 = 30 * 60;
36pub(crate) const DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT: &str = DEFAULT_MAINNET_ENDPOINT;
37pub(crate) const SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION: u32 = 1;
38pub(crate) const SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION: u32 = 1;
39pub(crate) const SUBNET_CATALOG_REFRESH_REPORT_SCHEMA_VERSION: u32 = 1;
40const BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS: u128 = 1_000_000_000;
41const FORMULA_VERSION: &str = "base_13_node_linear_v1";
42
43///
44/// CatalogError
45///
46#[derive(Debug, ThisError)]
47pub enum CatalogError {
48    #[error(transparent)]
49    Json(#[from] serde_json::Error),
50
51    #[error("unsupported subnet catalog schema version {found}; supported version is {supported}")]
52    UnsupportedSchemaVersion { found: u32, supported: u32 },
53
54    #[error("subnet catalog must contain at least one subnet")]
55    EmptySubnets,
56
57    #[error("subnet catalog must contain at least one routing range")]
58    EmptyRoutingRanges,
59
60    #[error("invalid principal in {field}: {value}: {reason}")]
61    InvalidPrincipal {
62        field: &'static str,
63        value: String,
64        reason: String,
65    },
66
67    #[error("duplicate subnet principal in catalog: {subnet_principal}")]
68    DuplicateSubnet { subnet_principal: String },
69
70    #[error("routing range references unknown subnet: {subnet_principal}")]
71    UnknownRoutingSubnet { subnet_principal: String },
72
73    #[error(
74        "invalid routing range for {subnet_principal}: start {start_canister_id} sorts after end {end_canister_id}"
75    )]
76    InvalidRoutingRange {
77        subnet_principal: String,
78        start_canister_id: String,
79        end_canister_id: String,
80    },
81
82    #[error("subnet principal {subnet_principal} was not found in the cached catalog")]
83    UnknownSubnet { subnet_principal: String },
84
85    #[error("principal prefix {prefix:?} did not match cached subnet principals")]
86    PrincipalPrefixNotFound { prefix: String },
87
88    #[error("principal prefix {prefix:?} is ambiguous; matches: {matches:?}")]
89    AmbiguousPrincipalPrefix {
90        prefix: String,
91        matches: Vec<String>,
92    },
93
94    #[error(
95        "canister principal {canister_principal} was not covered by cached routing ranges at registry_version={registry_version}, catalog_schema_version={catalog_schema_version}"
96    )]
97    RouteNotFound {
98        canister_principal: String,
99        registry_version: u64,
100        catalog_schema_version: u32,
101    },
102}
103
104/// Decode and validate one subnet catalog JSON payload.
105pub fn parse_catalog_json(data: &str) -> Result<SubnetCatalog, CatalogError> {
106    let catalog = serde_json::from_str::<SubnetCatalog>(data)?;
107    catalog.validate()?;
108    Ok(catalog)
109}
110
111/// Render one subnet catalog JSON payload with stable pretty formatting.
112pub fn catalog_to_pretty_json(catalog: &SubnetCatalog) -> Result<String, CatalogError> {
113    Ok(serde_json::to_string_pretty(catalog)?)
114}
115
116/// Parse a textual IC principal into canonical text.
117pub fn canonical_principal_text(value: &str) -> Result<String, CatalogError> {
118    Ok(parse_principal(value, "principal")?.to_text())
119}
120
121pub(crate) fn parse_principal(value: &str, field: &'static str) -> Result<Principal, CatalogError> {
122    Principal::from_text(value).map_err(|err| CatalogError::InvalidPrincipal {
123        field,
124        value: value.to_string(),
125        reason: err.to_string(),
126    })
127}
128
129pub(crate) fn principal_bytes(value: &str, field: &'static str) -> Result<Vec<u8>, CatalogError> {
130    Ok(parse_principal(value, field)?.as_slice().to_vec())
131}
132
133///
134/// SubnetCatalogCacheRequest
135///
136#[derive(Clone, Debug, Eq, PartialEq)]
137pub(crate) struct SubnetCatalogCacheRequest {
138    pub icp_root: PathBuf,
139    pub network: String,
140}
141
142///
143/// CachedSubnetCatalog
144///
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub(crate) struct CachedSubnetCatalog {
147    pub path: PathBuf,
148    pub catalog: SubnetCatalog,
149}
150
151///
152/// SubnetCatalogFilters
153///
154#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
155pub(crate) struct SubnetCatalogFilters {
156    pub kind: Option<SubnetKind>,
157    pub specialization: Option<SubnetSpecialization>,
158    pub geographic_scope: Option<GeographicScope>,
159}
160
161///
162/// SubnetCatalogListRequest
163///
164#[derive(Clone, Debug, Eq, PartialEq)]
165pub(crate) struct SubnetCatalogListRequest {
166    pub cache: SubnetCatalogCacheRequest,
167    pub source_endpoint: String,
168    pub now_unix_secs: u64,
169    pub stale_after_seconds: u64,
170    pub filters: SubnetCatalogFilters,
171    pub show_ranges: bool,
172    pub range_limit: usize,
173    pub range_offset: usize,
174}
175
176///
177/// SubnetCatalogInfoRequest
178///
179#[derive(Clone, Debug, Eq, PartialEq)]
180pub(crate) struct SubnetCatalogInfoRequest {
181    pub cache: SubnetCatalogCacheRequest,
182    pub source_endpoint: String,
183    pub input: String,
184    pub forced: Option<ResolveAs>,
185    pub now_unix_secs: u64,
186    pub stale_after_seconds: u64,
187}
188
189///
190/// SubnetCatalogRefreshRequest
191///
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub(crate) struct SubnetCatalogRefreshRequest {
194    pub cache: SubnetCatalogCacheRequest,
195    pub source_endpoint: String,
196    pub now_unix_secs: u64,
197    pub lock_stale_after_seconds: u64,
198    pub dry_run: bool,
199    pub output_path: Option<PathBuf>,
200}
201
202///
203/// CatalogStaleStatus
204///
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206pub(crate) struct CatalogStaleStatus {
207    pub catalog_stale: bool,
208    pub stale_reason: String,
209    pub stale_after_seconds: u64,
210    pub fetched_at_unix_secs: Option<u64>,
211    pub age_seconds: Option<u64>,
212}
213
214///
215/// SubnetCatalogListReport
216///
217#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
218pub(crate) struct SubnetCatalogListReport {
219    pub schema_version: u32,
220    pub network: String,
221    pub catalog_path: String,
222    pub catalog_schema_version: u32,
223    pub registry_canister_id: String,
224    pub registry_version: u64,
225    pub fetched_at: String,
226    pub catalog_stale: bool,
227    pub stale_reason: String,
228    pub resolver_backend: String,
229    pub subnets: Vec<SubnetCatalogSubnetRow>,
230}
231
232///
233/// SubnetCatalogSubnetRow
234///
235#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236pub(crate) struct SubnetCatalogSubnetRow {
237    pub subnet_principal: String,
238    pub subnet_kind: SubnetKind,
239    pub subnet_kind_source: ClassificationSource,
240    pub subnet_specialization: SubnetSpecialization,
241    pub subnet_specialization_source: ClassificationSource,
242    pub geographic_scope: GeographicScope,
243    pub geographic_scope_source: ClassificationSource,
244    pub subnet_label: String,
245    pub subnet_label_source: ClassificationSource,
246    pub node_count: Option<u32>,
247    pub charges_apply_by_default: bool,
248    pub range_count: usize,
249    pub ranges_shown: usize,
250    pub range_offset: usize,
251    pub range_limit: usize,
252    pub ranges: Vec<RoutingRange>,
253}
254
255///
256/// SubnetCatalogInfoReport
257///
258#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
259pub(crate) struct SubnetCatalogInfoReport {
260    pub schema_version: u32,
261    pub input_principal: String,
262    pub resolved_as: String,
263    pub resolved_from: String,
264    pub subnet_principal: String,
265    pub subnet_kind: SubnetKind,
266    pub subnet_kind_source: ClassificationSource,
267    pub subnet_specialization: SubnetSpecialization,
268    pub subnet_specialization_source: ClassificationSource,
269    pub geographic_scope: GeographicScope,
270    pub geographic_scope_source: ClassificationSource,
271    pub subnet_label: String,
272    pub subnet_label_source: ClassificationSource,
273    pub node_count: Option<u32>,
274    pub charges_apply_to_subject: bool,
275    pub charge_applicability_reason: String,
276    pub registry_canister_id: String,
277    pub registry_version: u64,
278    pub catalog_schema_version: u32,
279    pub catalog_path: String,
280    pub fetched_at: String,
281    pub catalog_stale: bool,
282    pub stale_reason: String,
283    pub resolver_backend: String,
284    pub matched_canister_principal: Option<String>,
285    pub matched_routing_range: Option<RoutingRange>,
286    pub cycles_per_billion_instructions: Option<u128>,
287    pub rate_source: Option<String>,
288    pub formula_version: Option<String>,
289}
290
291///
292/// SubnetCatalogRefreshReport
293///
294#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
295pub(crate) struct SubnetCatalogRefreshReport {
296    pub schema_version: u32,
297    pub network: String,
298    pub catalog_path: String,
299    pub refresh_lock_path: String,
300    pub output_path: Option<String>,
301    pub registry_canister_id: String,
302    pub registry_version: u64,
303    pub fetched_at: String,
304    pub source_endpoint: String,
305    pub fetched_by: String,
306    pub dry_run: bool,
307    pub wrote_catalog: bool,
308    pub replaced_existing_catalog: bool,
309    pub subnet_count: usize,
310    pub routing_range_count: usize,
311}
312
313///
314/// SubnetCatalogHostError
315///
316#[derive(Debug, ThisError)]
317pub(crate) enum SubnetCatalogHostError {
318    #[error(
319        "`icq nns subnet` supports only the mainnet `ic` network\n\nThe cached NNS subnet data describes the public Internet Computer mainnet.\nLocal replica subnet discovery is not implemented yet.\n\nTry:\n  icq --network ic nns subnet list"
320    )]
321    UnsupportedNetwork { network: String },
322
323    #[error(
324        "subnet catalog cache is missing at {}\n\nRun `icq nns subnet refresh` to fetch the public Internet Computer mainnet catalog, or populate this path with a valid subnet catalog JSON.",
325        path.display()
326    )]
327    MissingCatalog { path: PathBuf },
328
329    #[error("failed to read subnet catalog at {}: {source}", path.display())]
330    ReadCatalog { path: PathBuf, source: io::Error },
331
332    #[error(
333        "cached subnet catalog network mismatch: path is for {requested}, catalog is for {actual}"
334    )]
335    NetworkMismatch { requested: String, actual: String },
336
337    #[error(
338        "invalid stale duration {value:?}; use positive seconds or a value ending in s, m, h, or d"
339    )]
340    #[cfg(test)]
341    InvalidStaleDuration { value: String },
342
343    #[error("subnet catalog refresh is already in progress; lock exists at {} since unix_ms={started_at_unix_ms}", path.display())]
344    RefreshAlreadyInProgress {
345        path: PathBuf,
346        started_at_unix_ms: u64,
347    },
348
349    #[error("failed to create subnet catalog directory at {}: {source}", path.display())]
350    CreateCatalogDirectory { path: PathBuf, source: io::Error },
351
352    #[error("failed to create refresh lock at {}: {source}", path.display())]
353    CreateRefreshLock { path: PathBuf, source: io::Error },
354
355    #[error("failed to read refresh lock at {}: {source}", path.display())]
356    ReadRefreshLock { path: PathBuf, source: io::Error },
357
358    #[error("failed to parse refresh lock at {}: {source}", path.display())]
359    ParseRefreshLock {
360        path: PathBuf,
361        source: serde_json::Error,
362    },
363
364    #[error("failed to write refresh lock at {}: {source}", path.display())]
365    WriteRefreshLock { path: PathBuf, source: io::Error },
366
367    #[error("failed to remove refresh lock at {}: {source}", path.display())]
368    RemoveRefreshLock { path: PathBuf, source: io::Error },
369
370    #[error("live NNS registry refresh failed: {0}")]
371    RegistryRefresh(#[from] RegistryFetchError),
372
373    #[error("refreshed subnet catalog network mismatch: requested {requested}, fetched {actual}")]
374    RefreshNetworkMismatch { requested: String, actual: String },
375
376    #[error("failed to write subnet catalog temp file at {}: {source}", path.display())]
377    WriteCatalogTemp { path: PathBuf, source: io::Error },
378
379    #[error("failed to sync subnet catalog temp file at {}: {source}", path.display())]
380    SyncCatalogTemp { path: PathBuf, source: io::Error },
381
382    #[error("failed to replace subnet catalog at {} from {}: {source}", catalog_path.display(), temp_path.display())]
383    ReplaceCatalog {
384        temp_path: PathBuf,
385        catalog_path: PathBuf,
386        source: io::Error,
387    },
388
389    #[error("failed to sync subnet catalog directory at {}: {source}", path.display())]
390    SyncCatalogDirectory { path: PathBuf, source: io::Error },
391
392    #[error("failed to write refreshed subnet catalog output at {}: {source}", path.display())]
393    WriteRefreshOutput { path: PathBuf, source: io::Error },
394
395    #[error("failed to sync refreshed subnet catalog output at {}: {source}", path.display())]
396    SyncRefreshOutput { path: PathBuf, source: io::Error },
397
398    #[error(transparent)]
399    Catalog(#[from] CatalogError),
400}
401
402#[must_use]
403pub(crate) fn subnet_catalog_path(icp_root: &Path, network: &str) -> PathBuf {
404    icp_root
405        .join(".icq")
406        .join("subnet-catalog")
407        .join(network)
408        .join("catalog.json")
409}
410
411#[must_use]
412pub(crate) fn subnet_catalog_refresh_lock_path(icp_root: &Path, network: &str) -> PathBuf {
413    icp_root
414        .join(".icq")
415        .join("subnet-catalog")
416        .join(network)
417        .join("refresh.lock")
418}
419
420pub(crate) fn load_cached_subnet_catalog(
421    request: &SubnetCatalogCacheRequest,
422) -> Result<CachedSubnetCatalog, SubnetCatalogHostError> {
423    enforce_mainnet_network(&request.network)?;
424    let path = subnet_catalog_path(&request.icp_root, &request.network);
425    if !path.is_file() {
426        return Err(SubnetCatalogHostError::MissingCatalog { path });
427    }
428    let data = fs::read_to_string(&path).map_err(|source| SubnetCatalogHostError::ReadCatalog {
429        path: path.clone(),
430        source,
431    })?;
432    let catalog = parse_catalog_json(&data)?;
433    if catalog.network != request.network {
434        return Err(SubnetCatalogHostError::NetworkMismatch {
435            requested: request.network.clone(),
436            actual: catalog.network,
437        });
438    }
439    Ok(CachedSubnetCatalog { path, catalog })
440}
441
442pub(crate) fn refresh_subnet_catalog(
443    request: &SubnetCatalogRefreshRequest,
444) -> Result<SubnetCatalogRefreshReport, SubnetCatalogHostError> {
445    refresh_subnet_catalog_with_source(request, &LiveNnsRegistryRefreshSource)
446}
447
448fn refresh_subnet_catalog_with_source(
449    request: &SubnetCatalogRefreshRequest,
450    source: &dyn SubnetCatalogRefreshSource,
451) -> Result<SubnetCatalogRefreshReport, SubnetCatalogHostError> {
452    enforce_mainnet_network(&request.cache.network)?;
453    let catalog_path = subnet_catalog_path(&request.cache.icp_root, &request.cache.network);
454    let lock_path =
455        subnet_catalog_refresh_lock_path(&request.cache.icp_root, &request.cache.network);
456    let catalog_dir = catalog_path
457        .parent()
458        .expect("subnet catalog path always has parent")
459        .to_path_buf();
460    create_directory(&catalog_dir).map_err(subnet_cache_error)?;
461    let lock = acquire_refresh_lock(RefreshLockRequest {
462        lock_path: &lock_path,
463        target_path: &catalog_path,
464        network: &request.cache.network,
465        now_unix_secs: request.now_unix_secs,
466        lock_stale_after_seconds: request.lock_stale_after_seconds,
467    })
468    .map_err(subnet_cache_error)?;
469    let replaced_existing_catalog = catalog_path.is_file();
470    let fetched_at = format_utc_timestamp_secs(request.now_unix_secs);
471    let mut fetch_request = MainnetRegistryFetchRequest::new(fetched_at);
472    fetch_request.endpoint.clone_from(&request.source_endpoint);
473    let catalog = source.fetch_catalog(&fetch_request)?;
474    if catalog.network != request.cache.network {
475        return Err(SubnetCatalogHostError::RefreshNetworkMismatch {
476            requested: request.cache.network.clone(),
477            actual: catalog.network,
478        });
479    }
480    catalog.validate()?;
481    let catalog_json = catalog_to_pretty_json(&catalog)?;
482    if let Some(output_path) = &request.output_path {
483        write_text_output(output_path, &catalog_json).map_err(subnet_cache_error)?;
484    }
485    if !request.dry_run {
486        write_text_atomically(&catalog_path, &catalog_json).map_err(subnet_cache_error)?;
487    }
488    lock.release().map_err(subnet_cache_error)?;
489    Ok(SubnetCatalogRefreshReport {
490        schema_version: SUBNET_CATALOG_REFRESH_REPORT_SCHEMA_VERSION,
491        network: catalog.network,
492        catalog_path: catalog_path.display().to_string(),
493        refresh_lock_path: lock_path.display().to_string(),
494        output_path: request
495            .output_path
496            .as_ref()
497            .map(|path| path.display().to_string()),
498        registry_canister_id: catalog.registry_canister_id,
499        registry_version: catalog.registry_version,
500        fetched_at: catalog.fetched_at,
501        source_endpoint: catalog.source_endpoint,
502        fetched_by: catalog.fetched_by,
503        dry_run: request.dry_run,
504        wrote_catalog: !request.dry_run,
505        replaced_existing_catalog,
506        subnet_count: catalog.subnets.len(),
507        routing_range_count: catalog.routing_ranges.len(),
508    })
509}
510
511pub(crate) fn build_subnet_catalog_list_report(
512    request: &SubnetCatalogListRequest,
513) -> Result<SubnetCatalogListReport, SubnetCatalogHostError> {
514    build_subnet_catalog_list_report_with_source(request, &LiveNnsRegistryRefreshSource)
515}
516
517fn build_subnet_catalog_list_report_with_source(
518    request: &SubnetCatalogListRequest,
519    source: &dyn SubnetCatalogRefreshSource,
520) -> Result<SubnetCatalogListReport, SubnetCatalogHostError> {
521    let cached = load_or_refresh_subnet_catalog(
522        &request.cache,
523        &request.source_endpoint,
524        request.now_unix_secs,
525        source,
526    )?;
527    let stale = catalog_stale_status(
528        &cached.catalog,
529        request.now_unix_secs,
530        request.stale_after_seconds,
531    );
532    let subnets = cached
533        .catalog
534        .subnets
535        .iter()
536        .filter(|subnet| subnet_matches_filters(subnet, request.filters))
537        .map(|subnet| subnet_row(&cached.catalog, subnet, request))
538        .collect::<Vec<_>>();
539
540    Ok(SubnetCatalogListReport {
541        schema_version: SUBNET_CATALOG_LIST_REPORT_SCHEMA_VERSION,
542        network: cached.catalog.network,
543        catalog_path: cached.path.display().to_string(),
544        catalog_schema_version: cached.catalog.catalog_schema_version,
545        registry_canister_id: cached.catalog.registry_canister_id,
546        registry_version: cached.catalog.registry_version,
547        fetched_at: cached.catalog.fetched_at,
548        catalog_stale: stale.catalog_stale,
549        stale_reason: stale.stale_reason,
550        resolver_backend: cached.catalog.resolver_backend,
551        subnets,
552    })
553}
554
555pub(crate) fn build_subnet_catalog_info_report(
556    request: &SubnetCatalogInfoRequest,
557) -> Result<SubnetCatalogInfoReport, SubnetCatalogHostError> {
558    build_subnet_catalog_info_report_with_source(request, &LiveNnsRegistryRefreshSource)
559}
560
561fn build_subnet_catalog_info_report_with_source(
562    request: &SubnetCatalogInfoRequest,
563    source: &dyn SubnetCatalogRefreshSource,
564) -> Result<SubnetCatalogInfoReport, SubnetCatalogHostError> {
565    let cached = load_or_refresh_subnet_catalog(
566        &request.cache,
567        &request.source_endpoint,
568        request.now_unix_secs,
569        source,
570    )?;
571    let stale = catalog_stale_status(
572        &cached.catalog,
573        request.now_unix_secs,
574        request.stale_after_seconds,
575    );
576    let resolved = cached
577        .catalog
578        .resolve_principal_or_prefix(&request.input, request.forced)?;
579    let (charges_apply_to_subject, charge_applicability_reason) =
580        charge_applicability(resolved.resolved_as, resolved.subnet.subnet_kind);
581    let cycles_per_billion_instructions = catalog_cycles_per_billion(&resolved.subnet);
582    let rate_source = cycles_per_billion_instructions
583        .is_some()
584        .then(|| "nns-registry-cache".to_string());
585    let formula_version = cycles_per_billion_instructions
586        .is_some()
587        .then(|| FORMULA_VERSION.to_string());
588
589    Ok(SubnetCatalogInfoReport {
590        schema_version: SUBNET_CATALOG_INFO_REPORT_SCHEMA_VERSION,
591        input_principal: resolved.input_principal,
592        resolved_as: resolved.resolved_as.as_str().to_string(),
593        resolved_from: resolved.resolved_from,
594        subnet_principal: resolved.subnet.subnet_principal,
595        subnet_kind: resolved.subnet.subnet_kind,
596        subnet_kind_source: resolved.subnet.subnet_kind_source,
597        subnet_specialization: resolved.subnet.subnet_specialization,
598        subnet_specialization_source: resolved.subnet.subnet_specialization_source,
599        geographic_scope: resolved.subnet.geographic_scope,
600        geographic_scope_source: resolved.subnet.geographic_scope_source,
601        subnet_label: resolved.subnet.subnet_label,
602        subnet_label_source: resolved.subnet.subnet_label_source,
603        node_count: resolved.subnet.node_count,
604        charges_apply_to_subject,
605        charge_applicability_reason,
606        registry_canister_id: cached.catalog.registry_canister_id,
607        registry_version: cached.catalog.registry_version,
608        catalog_schema_version: cached.catalog.catalog_schema_version,
609        catalog_path: cached.path.display().to_string(),
610        fetched_at: cached.catalog.fetched_at,
611        catalog_stale: stale.catalog_stale,
612        stale_reason: stale.stale_reason,
613        resolver_backend: cached.catalog.resolver_backend,
614        matched_canister_principal: resolved.matched_canister_principal,
615        matched_routing_range: resolved.matched_routing_range,
616        cycles_per_billion_instructions,
617        rate_source,
618        formula_version,
619    })
620}
621
622fn load_or_refresh_subnet_catalog(
623    request: &SubnetCatalogCacheRequest,
624    source_endpoint: &str,
625    now_unix_secs: u64,
626    source: &dyn SubnetCatalogRefreshSource,
627) -> Result<CachedSubnetCatalog, SubnetCatalogHostError> {
628    match load_cached_subnet_catalog(request) {
629        Ok(cached) => Ok(cached),
630        Err(SubnetCatalogHostError::MissingCatalog { path }) => {
631            announce_cache_refresh("subnet catalog", &path, source_endpoint);
632            let refresh_request = SubnetCatalogRefreshRequest {
633                cache: request.clone(),
634                source_endpoint: source_endpoint.to_string(),
635                now_unix_secs,
636                lock_stale_after_seconds: DEFAULT_REFRESH_LOCK_STALE_SECONDS,
637                dry_run: false,
638                output_path: None,
639            };
640            refresh_subnet_catalog_with_source(&refresh_request, source)?;
641            load_cached_subnet_catalog(request)
642        }
643        Err(err) => Err(err),
644    }
645}
646
647#[must_use]
648pub(crate) fn catalog_stale_status(
649    catalog: &SubnetCatalog,
650    now_unix_secs: u64,
651    stale_after_seconds: u64,
652) -> CatalogStaleStatus {
653    let Some(fetched_at_unix_secs) = parse_utc_timestamp_secs(&catalog.fetched_at) else {
654        return CatalogStaleStatus {
655            catalog_stale: true,
656            stale_reason: "fetched_at_unparseable".to_string(),
657            stale_after_seconds,
658            fetched_at_unix_secs: None,
659            age_seconds: None,
660        };
661    };
662    let Some(age_seconds) = now_unix_secs.checked_sub(fetched_at_unix_secs) else {
663        return CatalogStaleStatus {
664            catalog_stale: false,
665            stale_reason: "fetched_at_in_future".to_string(),
666            stale_after_seconds,
667            fetched_at_unix_secs: Some(fetched_at_unix_secs),
668            age_seconds: None,
669        };
670    };
671    let catalog_stale = age_seconds > stale_after_seconds;
672    CatalogStaleStatus {
673        catalog_stale,
674        stale_reason: if catalog_stale { "expired" } else { "fresh" }.to_string(),
675        stale_after_seconds,
676        fetched_at_unix_secs: Some(fetched_at_unix_secs),
677        age_seconds: Some(age_seconds),
678    }
679}
680
681#[cfg(test)]
682#[cfg(test)]
683pub(crate) fn parse_stale_after_duration(value: &str) -> Result<u64, SubnetCatalogHostError> {
684    parse_duration_seconds(value).map_err(|_| SubnetCatalogHostError::InvalidStaleDuration {
685        value: value.to_string(),
686    })
687}
688
689fn subnet_cache_error(err: CacheFileError) -> SubnetCatalogHostError {
690    match err {
691        CacheFileError::CreateDirectory { path, source } => {
692            SubnetCatalogHostError::CreateCatalogDirectory { path, source }
693        }
694        CacheFileError::CreateRefreshLock { path, source } => {
695            SubnetCatalogHostError::CreateRefreshLock { path, source }
696        }
697        CacheFileError::ReadRefreshLock { path, source } => {
698            SubnetCatalogHostError::ReadRefreshLock { path, source }
699        }
700        CacheFileError::ParseRefreshLock { path, source } => {
701            SubnetCatalogHostError::ParseRefreshLock { path, source }
702        }
703        CacheFileError::WriteRefreshLock { path, source } => {
704            SubnetCatalogHostError::WriteRefreshLock { path, source }
705        }
706        CacheFileError::RemoveRefreshLock { path, source } => {
707            SubnetCatalogHostError::RemoveRefreshLock { path, source }
708        }
709        CacheFileError::RefreshAlreadyInProgress {
710            path,
711            started_at_unix_ms,
712        } => SubnetCatalogHostError::RefreshAlreadyInProgress {
713            path,
714            started_at_unix_ms,
715        },
716        CacheFileError::WriteTemp { path, source } => {
717            SubnetCatalogHostError::WriteCatalogTemp { path, source }
718        }
719        CacheFileError::SyncTemp { path, source } => {
720            SubnetCatalogHostError::SyncCatalogTemp { path, source }
721        }
722        CacheFileError::Replace {
723            temp_path,
724            target_path,
725            source,
726        } => SubnetCatalogHostError::ReplaceCatalog {
727            temp_path,
728            catalog_path: target_path,
729            source,
730        },
731        CacheFileError::SyncDirectory { path, source } => {
732            SubnetCatalogHostError::SyncCatalogDirectory { path, source }
733        }
734        CacheFileError::WriteOutput { path, source } => {
735            SubnetCatalogHostError::WriteRefreshOutput { path, source }
736        }
737        CacheFileError::SyncOutput { path, source } => {
738            SubnetCatalogHostError::SyncRefreshOutput { path, source }
739        }
740    }
741}
742
743#[must_use]
744pub(crate) fn subnet_catalog_list_report_text(report: &SubnetCatalogListReport) -> String {
745    let headers = [
746        "SUBNET", "KIND", "SPEC", "GEO", "NODES", "CHG", "RANGES", "STALE",
747    ];
748    let rows = report
749        .subnets
750        .iter()
751        .map(|subnet| {
752            [
753                compact_principal(&subnet.subnet_principal),
754                subnet.subnet_kind.as_str().to_string(),
755                subnet.subnet_specialization.as_str().to_string(),
756                subnet.geographic_scope.as_str().to_string(),
757                subnet
758                    .node_count
759                    .map_or_else(|| "unknown".to_string(), |count| count.to_string()),
760                yes_no(subnet.charges_apply_by_default).to_string(),
761                subnet.range_count.to_string(),
762                yes_no(report.catalog_stale).to_string(),
763            ]
764        })
765        .collect::<Vec<_>>();
766    let alignments = [
767        ColumnAlign::Left,
768        ColumnAlign::Left,
769        ColumnAlign::Left,
770        ColumnAlign::Left,
771        ColumnAlign::Right,
772        ColumnAlign::Left,
773        ColumnAlign::Right,
774        ColumnAlign::Left,
775    ];
776    let mut lines = Vec::new();
777    lines.push(format!(
778        "catalog: {} version {} stale {}",
779        report.network,
780        report.registry_version,
781        yes_no(report.catalog_stale)
782    ));
783    if rows.is_empty() {
784        lines.push("subnets: none".to_string());
785        return lines.join("\n");
786    }
787    lines.push(render_table(&headers, &rows, &alignments));
788    append_compact_range_lines(report, &mut lines);
789    lines.join("\n")
790}
791
792#[must_use]
793pub(crate) fn subnet_catalog_list_report_verbose_text(report: &SubnetCatalogListReport) -> String {
794    let headers = [
795        "SUBNET",
796        "KIND",
797        "SPECIALIZATION",
798        "GEO",
799        "NODES",
800        "CHARGES",
801        "RANGES",
802        "VERSION",
803        "FETCHED_AT",
804        "STALE",
805    ];
806    let rows = report
807        .subnets
808        .iter()
809        .map(|subnet| {
810            [
811                subnet.subnet_principal.clone(),
812                subnet.subnet_kind.as_str().to_string(),
813                subnet.subnet_specialization.as_str().to_string(),
814                subnet.geographic_scope.as_str().to_string(),
815                subnet
816                    .node_count
817                    .map_or_else(|| "unknown".to_string(), |count| count.to_string()),
818                yes_no(subnet.charges_apply_by_default).to_string(),
819                subnet.range_count.to_string(),
820                report.registry_version.to_string(),
821                report.fetched_at.clone(),
822                yes_no(report.catalog_stale).to_string(),
823            ]
824        })
825        .collect::<Vec<_>>();
826    let alignments = [
827        ColumnAlign::Left,
828        ColumnAlign::Left,
829        ColumnAlign::Left,
830        ColumnAlign::Left,
831        ColumnAlign::Right,
832        ColumnAlign::Left,
833        ColumnAlign::Right,
834        ColumnAlign::Right,
835        ColumnAlign::Left,
836        ColumnAlign::Left,
837    ];
838    let mut lines = Vec::new();
839    lines.push(format!("catalog_path: {}", report.catalog_path));
840    lines.push(format!("stale_reason: {}", report.stale_reason));
841    if rows.is_empty() {
842        lines.push("subnets: none".to_string());
843        return lines.join("\n");
844    }
845    lines.push(render_table(&headers, &rows, &alignments));
846    append_range_lines(report, &mut lines);
847    lines.join("\n")
848}
849
850#[must_use]
851pub(crate) fn subnet_catalog_info_report_text(report: &SubnetCatalogInfoReport) -> String {
852    let mut lines = Vec::new();
853    lines.push(format!("input_principal: {}", report.input_principal));
854    lines.push(format!("resolved_as: {}", report.resolved_as));
855    lines.push(format!("resolved_from: {}", report.resolved_from));
856    lines.push(format!("subnet_principal: {}", report.subnet_principal));
857    lines.push(format!("subnet_kind: {}", report.subnet_kind.as_str()));
858    lines.push(format!(
859        "subnet_kind_source: {}",
860        report.subnet_kind_source.as_str()
861    ));
862    lines.push(format!(
863        "subnet_specialization: {}",
864        report.subnet_specialization.as_str()
865    ));
866    lines.push(format!(
867        "subnet_specialization_source: {}",
868        report.subnet_specialization_source.as_str()
869    ));
870    lines.push(format!(
871        "geographic_scope: {}",
872        report.geographic_scope.as_str()
873    ));
874    lines.push(format!(
875        "geographic_scope_source: {}",
876        report.geographic_scope_source.as_str()
877    ));
878    lines.push(format!("subnet_label: {}", report.subnet_label));
879    lines.push(format!(
880        "subnet_label_source: {}",
881        report.subnet_label_source.as_str()
882    ));
883    lines.push(format!(
884        "node_count: {}",
885        report
886            .node_count
887            .map_or_else(|| "unknown".to_string(), |count| count.to_string())
888    ));
889    lines.push(format!(
890        "charges_apply_to_subject: {}",
891        yes_no(report.charges_apply_to_subject)
892    ));
893    lines.push(format!(
894        "charge_applicability_reason: {}",
895        report.charge_applicability_reason
896    ));
897    lines.push(format!(
898        "registry_canister_id: {}",
899        report.registry_canister_id
900    ));
901    lines.push(format!("registry_version: {}", report.registry_version));
902    lines.push(format!(
903        "catalog_schema_version: {}",
904        report.catalog_schema_version
905    ));
906    lines.push(format!("catalog_path: {}", report.catalog_path));
907    lines.push(format!("fetched_at: {}", report.fetched_at));
908    lines.push(format!("catalog_stale: {}", yes_no(report.catalog_stale)));
909    lines.push(format!("stale_reason: {}", report.stale_reason));
910    lines.push(format!("resolver_backend: {}", report.resolver_backend));
911    if let Some(canister) = &report.matched_canister_principal {
912        lines.push(format!("matched_canister_principal: {canister}"));
913    }
914    if let Some(range) = &report.matched_routing_range {
915        lines.push(format!(
916            "matched_routing_range: {}..{}",
917            range.start_canister_id, range.end_canister_id
918        ));
919    }
920    lines.push(format!(
921        "cycles_per_billion_instructions: {}",
922        report
923            .cycles_per_billion_instructions
924            .map_or_else(|| "not_applicable".to_string(), |cycles| cycles.to_string())
925    ));
926    if let Some(rate_source) = &report.rate_source {
927        lines.push(format!("rate_source: {rate_source}"));
928    }
929    if let Some(formula_version) = &report.formula_version {
930        lines.push(format!("formula_version: {formula_version}"));
931    }
932    lines.join("\n")
933}
934
935#[must_use]
936pub(crate) fn subnet_catalog_refresh_report_text(report: &SubnetCatalogRefreshReport) -> String {
937    [
938        format!("network: {}", report.network),
939        format!("catalog_path: {}", report.catalog_path),
940        format!("refresh_lock_path: {}", report.refresh_lock_path),
941        format!("registry_canister_id: {}", report.registry_canister_id),
942        format!("registry_version: {}", report.registry_version),
943        format!("fetched_at: {}", report.fetched_at),
944        format!("source_endpoint: {}", report.source_endpoint),
945        format!("fetched_by: {}", report.fetched_by),
946        format!("dry_run: {}", yes_no(report.dry_run)),
947        format!("wrote_catalog: {}", yes_no(report.wrote_catalog)),
948        format!(
949            "replaced_existing_catalog: {}",
950            yes_no(report.replaced_existing_catalog)
951        ),
952        format!("subnet_count: {}", report.subnet_count),
953        format!("routing_range_count: {}", report.routing_range_count),
954    ]
955    .join("\n")
956}
957
958fn enforce_mainnet_network(network: &str) -> Result<(), SubnetCatalogHostError> {
959    if network == MAINNET_NETWORK {
960        return Ok(());
961    }
962    Err(SubnetCatalogHostError::UnsupportedNetwork {
963        network: network.to_string(),
964    })
965}
966
967trait SubnetCatalogRefreshSource {
968    fn fetch_catalog(
969        &self,
970        request: &MainnetRegistryFetchRequest,
971    ) -> Result<SubnetCatalog, SubnetCatalogHostError>;
972}
973
974///
975/// LiveNnsRegistryRefreshSource
976///
977struct LiveNnsRegistryRefreshSource;
978
979impl SubnetCatalogRefreshSource for LiveNnsRegistryRefreshSource {
980    fn fetch_catalog(
981        &self,
982        request: &MainnetRegistryFetchRequest,
983    ) -> Result<SubnetCatalog, SubnetCatalogHostError> {
984        Ok(fetch_mainnet_subnet_catalog(request)?)
985    }
986}
987
988fn subnet_matches_filters(subnet: &SubnetInfo, filters: SubnetCatalogFilters) -> bool {
989    filters.kind.is_none_or(|kind| subnet.subnet_kind == kind)
990        && filters
991            .specialization
992            .is_none_or(|specialization| subnet.subnet_specialization == specialization)
993        && filters
994            .geographic_scope
995            .is_none_or(|scope| subnet.geographic_scope == scope)
996}
997
998fn subnet_row(
999    catalog: &SubnetCatalog,
1000    subnet: &SubnetInfo,
1001    request: &SubnetCatalogListRequest,
1002) -> SubnetCatalogSubnetRow {
1003    let ranges = catalog.routing_ranges_for_subnet(&subnet.subnet_principal);
1004    let range_count = ranges.len();
1005    let shown_ranges = if request.show_ranges {
1006        ranges
1007            .into_iter()
1008            .skip(request.range_offset)
1009            .take(request.range_limit)
1010            .cloned()
1011            .collect::<Vec<_>>()
1012    } else {
1013        Vec::new()
1014    };
1015    SubnetCatalogSubnetRow {
1016        subnet_principal: subnet.subnet_principal.clone(),
1017        subnet_kind: subnet.subnet_kind,
1018        subnet_kind_source: subnet.subnet_kind_source,
1019        subnet_specialization: subnet.subnet_specialization,
1020        subnet_specialization_source: subnet.subnet_specialization_source,
1021        geographic_scope: subnet.geographic_scope,
1022        geographic_scope_source: subnet.geographic_scope_source,
1023        subnet_label: subnet.subnet_label.clone(),
1024        subnet_label_source: subnet.subnet_label_source,
1025        node_count: subnet.node_count,
1026        charges_apply_by_default: subnet.charges_apply_by_default,
1027        range_count,
1028        ranges_shown: shown_ranges.len(),
1029        range_offset: request.range_offset,
1030        range_limit: request.range_limit,
1031        ranges: shown_ranges,
1032    }
1033}
1034
1035fn charge_applicability(subject: ResolvedSubnetSubject, kind: SubnetKind) -> (bool, String) {
1036    match kind {
1037        SubnetKind::Application | SubnetKind::CloudEngine => {
1038            (true, "charged_user_canister_subnet".to_string())
1039        }
1040        SubnetKind::System if subject == ResolvedSubnetSubject::Subnet => {
1041            (false, "system_subnet_core_canister".to_string())
1042        }
1043        SubnetKind::System => (false, "system_subnet_unknown_subject".to_string()),
1044        SubnetKind::Unknown => (false, "unknown_subnet_type".to_string()),
1045    }
1046}
1047
1048fn catalog_cycles_per_billion(subnet: &SubnetInfo) -> Option<u128> {
1049    if !subnet.subnet_kind.charges_apply_by_default() {
1050        return None;
1051    }
1052    let node_count = u128::from(subnet.node_count?);
1053    if node_count == 0 {
1054        return None;
1055    }
1056    Some(ceil_div(
1057        BASE_13_NODE_CYCLES_PER_BILLION_INSTRUCTIONS * node_count,
1058        13,
1059    ))
1060}
1061
1062const fn ceil_div(numerator: u128, denominator: u128) -> u128 {
1063    numerator.div_ceil(denominator)
1064}
1065
1066fn append_range_lines(report: &SubnetCatalogListReport, lines: &mut Vec<String>) {
1067    for subnet in &report.subnets {
1068        if subnet.ranges.is_empty() {
1069            continue;
1070        }
1071        lines.push(format!("ranges for {}:", subnet.subnet_principal));
1072        for range in &subnet.ranges {
1073            lines.push(format!(
1074                "  {}..{}",
1075                range.start_canister_id, range.end_canister_id
1076            ));
1077        }
1078        if subnet.ranges_shown < subnet.range_count {
1079            lines.push(format!(
1080                "  showing {} of {} ranges; use --range-limit or --format json",
1081                subnet.ranges_shown, subnet.range_count
1082            ));
1083        }
1084    }
1085}
1086
1087fn append_compact_range_lines(report: &SubnetCatalogListReport, lines: &mut Vec<String>) {
1088    for subnet in &report.subnets {
1089        if subnet.ranges.is_empty() {
1090            continue;
1091        }
1092        lines.push(format!(
1093            "ranges for {}:",
1094            compact_principal(&subnet.subnet_principal)
1095        ));
1096        for range in &subnet.ranges {
1097            lines.push(format!(
1098                "  {}..{}",
1099                compact_principal(&range.start_canister_id),
1100                compact_principal(&range.end_canister_id)
1101            ));
1102        }
1103        if subnet.ranges_shown < subnet.range_count {
1104            lines.push(format!(
1105                "  showing {} of {} ranges; use --range-limit or --format json",
1106                subnet.ranges_shown, subnet.range_count
1107            ));
1108        }
1109    }
1110}
1111
1112fn compact_principal(value: &str) -> String {
1113    value.chars().take(5).collect()
1114}
1115
1116fn parse_utc_timestamp_secs(value: &str) -> Option<u64> {
1117    let value = value.strip_suffix('Z')?;
1118    let (date, time) = value.split_once('T')?;
1119    let mut date_parts = date.split('-');
1120    let year = date_parts.next()?.parse::<i64>().ok()?;
1121    let month = date_parts.next()?.parse::<u32>().ok()?;
1122    let day = date_parts.next()?.parse::<u32>().ok()?;
1123    if date_parts.next().is_some() {
1124        return None;
1125    }
1126    let mut time_parts = time.split(':');
1127    let hour = time_parts.next()?.parse::<u32>().ok()?;
1128    let minute = time_parts.next()?.parse::<u32>().ok()?;
1129    let second = time_parts.next()?.parse::<u32>().ok()?;
1130    if time_parts.next().is_some()
1131        || !(1..=12).contains(&month)
1132        || !(1..=31).contains(&day)
1133        || hour > 23
1134        || minute > 59
1135        || second > 59
1136    {
1137        return None;
1138    }
1139    let days = days_from_civil(year, month, day)?;
1140    let seconds = days
1141        .checked_mul(86_400)?
1142        .checked_add(i64::from(hour) * 3_600)?
1143        .checked_add(i64::from(minute) * 60)?
1144        .checked_add(i64::from(second))?;
1145    u64::try_from(seconds).ok()
1146}
1147
1148pub(crate) fn format_utc_timestamp_secs(value: u64) -> String {
1149    let days = i64::try_from(value / 86_400).unwrap_or(i64::MAX);
1150    let seconds_of_day = value % 86_400;
1151    let (year, month, day) = civil_from_days(days);
1152    let hour = seconds_of_day / 3_600;
1153    let minute = (seconds_of_day % 3_600) / 60;
1154    let second = seconds_of_day % 60;
1155    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
1156}
1157
1158fn civil_from_days(days: i64) -> (i64, u32, u32) {
1159    let days = days + 719_468;
1160    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
1161    let day_of_era = days - era * 146_097;
1162    let year_of_era =
1163        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
1164    let mut year = year_of_era + era * 400;
1165    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
1166    let month_prime = (5 * day_of_year + 2) / 153;
1167    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
1168    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
1169    year += i64::from(month <= 2);
1170    (
1171        year,
1172        u32::try_from(month).expect("civil month is in u32 range"),
1173        u32::try_from(day).expect("civil day is in u32 range"),
1174    )
1175}
1176
1177fn days_from_civil(year: i64, month: u32, day: u32) -> Option<i64> {
1178    let month = i64::from(month);
1179    let day = i64::from(day);
1180    let year = year - i64::from(month <= 2);
1181    let era = if year >= 0 { year } else { year - 399 } / 400;
1182    let year_of_era = year - era * 400;
1183    let month_prime = month + if month > 2 { -3 } else { 9 };
1184    let day_of_year = (153 * month_prime + 2) / 5 + day - 1;
1185    let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
1186    era.checked_mul(146_097)?
1187        .checked_add(day_of_era)?
1188        .checked_sub(719_468)
1189}
1190
1191#[cfg(test)]
1192mod core_tests;
1193#[cfg(test)]
1194mod tests;