canic_core/config/schema/
mod.rs

1mod log;
2mod subnet;
3
4pub use log::*;
5pub use subnet::*;
6
7use crate::{
8    Error, ThisError,
9    config::ConfigError,
10    ids::{CanisterRole, SubnetRole},
11};
12use candid::Principal;
13use serde::{Deserialize, Serialize};
14use std::collections::{BTreeMap, BTreeSet};
15
16///
17/// ConfigSchemaError
18///
19
20#[derive(Debug, ThisError)]
21pub enum ConfigSchemaError {
22    #[error("validation error: {0}")]
23    ValidationError(String),
24}
25
26pub const NAME_MAX_BYTES: usize = 40;
27
28fn validate_canister_role_len(role: &CanisterRole, context: &str) -> Result<(), ConfigSchemaError> {
29    if role.as_ref().len() > NAME_MAX_BYTES {
30        return Err(ConfigSchemaError::ValidationError(format!(
31            "{context} '{role}' exceeds {NAME_MAX_BYTES} bytes",
32        )));
33    }
34
35    Ok(())
36}
37
38fn validate_subnet_role_len(role: &SubnetRole, context: &str) -> Result<(), ConfigSchemaError> {
39    if role.as_ref().len() > NAME_MAX_BYTES {
40        return Err(ConfigSchemaError::ValidationError(format!(
41            "{context} '{role}' exceeds {NAME_MAX_BYTES} bytes",
42        )));
43    }
44
45    Ok(())
46}
47
48impl From<ConfigSchemaError> for Error {
49    fn from(err: ConfigSchemaError) -> Self {
50        ConfigError::from(err).into()
51    }
52}
53
54///
55/// Validate
56///
57
58pub trait Validate {
59    fn validate(&self) -> Result<(), ConfigSchemaError>;
60}
61
62///
63/// ConfigModel
64///
65
66#[derive(Clone, Debug, Default, Deserialize, Serialize)]
67#[serde(deny_unknown_fields)]
68pub struct ConfigModel {
69    // controllers
70    // a vec because we just append it to the controller arguments
71    #[serde(default)]
72    pub controllers: Vec<Principal>,
73
74    #[serde(default)]
75    pub standards: Option<Standards>,
76
77    #[serde(default)]
78    pub log: LogConfig,
79
80    #[serde(default)]
81    pub app_directory: BTreeSet<CanisterRole>,
82
83    #[serde(default)]
84    pub subnets: BTreeMap<SubnetRole, SubnetConfig>,
85
86    #[serde(default)]
87    pub whitelist: Option<Whitelist>,
88}
89
90impl ConfigModel {
91    /// Get a subnet configuration by role.
92    #[must_use]
93    pub fn get_subnet(&self, role: &SubnetRole) -> Option<SubnetConfig> {
94        self.subnets.get(role).cloned()
95    }
96
97    /// Test-only: baseline config with a prime subnet so validation succeeds.
98    #[cfg(test)]
99    #[must_use]
100    pub fn test_default() -> Self {
101        let mut cfg = Self::default();
102        cfg.subnets
103            .insert(SubnetRole::PRIME, SubnetConfig::default());
104        cfg
105    }
106
107    /// Return true if the given principal is present in the whitelist.
108    #[must_use]
109    pub fn is_whitelisted(&self, principal: &Principal) -> bool {
110        self.whitelist
111            .as_ref()
112            .is_none_or(|w| w.principals.contains(&principal.to_string()))
113    }
114
115    /// Return whether ICRC-21 standard support is enabled.
116    #[must_use]
117    pub fn icrc21_enabled(&self) -> bool {
118        self.standards.as_ref().is_some_and(|s| s.icrc21)
119    }
120}
121
122impl Validate for ConfigModel {
123    fn validate(&self) -> Result<(), ConfigSchemaError> {
124        for subnet_role in self.subnets.keys() {
125            validate_subnet_role_len(subnet_role, "subnet")?;
126        }
127
128        self.log.validate()?;
129
130        // Validate that prime subnet exists
131        let prime = SubnetRole::PRIME;
132        let prime_subnet = self
133            .subnets
134            .get(&prime)
135            .ok_or_else(|| ConfigSchemaError::ValidationError("prime subnet not found".into()))?;
136
137        // Validate that every app_directory entry exists in prime.canisters
138        for canister_role in &self.app_directory {
139            validate_canister_role_len(canister_role, "app directory canister")?;
140            let canister_cfg = prime_subnet.canisters.get(canister_role).ok_or_else(|| {
141                ConfigSchemaError::ValidationError(format!(
142                    "app directory canister '{canister_role}' is not in prime subnet",
143                ))
144            })?;
145
146            if canister_cfg.cardinality != CanisterCardinality::Single {
147                return Err(ConfigSchemaError::ValidationError(format!(
148                    "app directory canister '{canister_role}' must have cardinality = \"single\"",
149                )));
150            }
151        }
152
153        // child validation
154        if let Some(list) = &self.whitelist {
155            list.validate()?;
156        }
157        for subnet in self.subnets.values() {
158            subnet.validate()?;
159        }
160
161        Ok(())
162    }
163}
164
165///
166/// Whitelist
167///
168
169#[derive(Clone, Debug, Default, Deserialize, Serialize)]
170#[serde(deny_unknown_fields)]
171pub struct Whitelist {
172    // principals
173    // a hashset as we constantly have to do lookups
174    // strings because then we can validate and know if there are any bad ones
175    #[serde(default)]
176    pub principals: BTreeSet<String>,
177}
178
179impl Validate for Whitelist {
180    fn validate(&self) -> Result<(), ConfigSchemaError> {
181        for (i, s) in self.principals.iter().enumerate() {
182            if Principal::from_text(s).is_err() {
183                return Err(ConfigSchemaError::ValidationError(format!(
184                    "principal #{i} {s} is invalid"
185                )));
186            }
187        }
188
189        Ok(())
190    }
191}
192
193///
194/// Standards
195///
196
197#[derive(Clone, Debug, Default, Deserialize, Serialize)]
198#[serde(deny_unknown_fields)]
199pub struct Standards {
200    #[serde(default)]
201    pub icrc21: bool,
202
203    #[serde(default)]
204    pub icrc103: bool,
205}