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