Skip to main content

icydb_model/node/
canister.rs

1//! Module: node::canister
2//!
3//! Responsibility: canister-level schema node metadata and memory allocation validation.
4//! Does not own: ICP lifecycle management or runtime stable-memory implementation.
5//! Boundary: validates declared memory ranges and stable keys before runtime use.
6
7#[cfg(test)]
8mod tests;
9
10use crate::node::{
11    stable_memory_key, validate_app_memory_id, validate_memory_id_in_range,
12    validate_memory_id_not_reserved, validate_stable_key, validate_stable_key_segment,
13};
14use crate::prelude::*;
15use std::collections::BTreeMap;
16use std::str::FromStr;
17
18/// Build-time constructor for one source-declared coordinated migration plan.
19pub type MigrationPlanConstructor =
20    fn() -> Result<icydb_schema::SchemaMigrationPlan, icydb_schema::SchemaContractError>;
21
22/// Parse one macro-validated textual migration literal into its exact public atom.
23#[doc(hidden)]
24pub fn migration_literal_from_text(
25    kind: &str,
26    value: &str,
27) -> Result<icydb_schema::ScalarLiteral, icydb_schema::SchemaContractError> {
28    use icydb_schema::{
29        Account, Blob, Date, Decimal, Duration, IntBig, NatBig, Principal, ScalarLiteral,
30        Subaccount, Timestamp, Ulid,
31    };
32
33    let invalid = || icydb_schema::SchemaContractError::InvalidLiteral;
34    match kind {
35        "account" => Account::from_str(value)
36            .map(ScalarLiteral::Account)
37            .map_err(|_| invalid()),
38        "blob" => decode_migration_hex(value)
39            .map(Blob::from)
40            .map(ScalarLiteral::Blob),
41        "date" => Date::parse(value)
42            .map(ScalarLiteral::Date)
43            .ok_or_else(invalid),
44        "decimal" => Decimal::from_str(value)
45            .map(ScalarLiteral::Decimal)
46            .map_err(|_| invalid()),
47        "duration" => Duration::parse_flexible(value)
48            .map(ScalarLiteral::Duration)
49            .map_err(|_| invalid()),
50        "int_big" => IntBig::from_str(value)
51            .map(ScalarLiteral::IntBig)
52            .map_err(|_| invalid()),
53        "nat_big" => NatBig::from_str(value)
54            .map(ScalarLiteral::NatBig)
55            .map_err(|_| invalid()),
56        "principal" => Principal::from_str(value)
57            .map(ScalarLiteral::Principal)
58            .map_err(|_| invalid()),
59        "subaccount" => {
60            let bytes = decode_migration_hex(value)?;
61            let bytes: [u8; 32] = bytes.try_into().map_err(|_| invalid())?;
62            Ok(ScalarLiteral::Subaccount(Subaccount::from_array(bytes)))
63        }
64        "timestamp" => Timestamp::parse_flexible(value)
65            .map(ScalarLiteral::Timestamp)
66            .map_err(|_| invalid()),
67        "ulid" => Ulid::from_str(value)
68            .map(ScalarLiteral::Ulid)
69            .map_err(|_| invalid()),
70        _ => Err(invalid()),
71    }
72}
73
74fn decode_migration_hex(value: &str) -> Result<Vec<u8>, icydb_schema::SchemaContractError> {
75    if !value.len().is_multiple_of(2) {
76        return Err(icydb_schema::SchemaContractError::InvalidLiteral);
77    }
78    value
79        .as_bytes()
80        .chunks_exact(2)
81        .map(|pair| {
82            let high = decode_hex_nibble(pair[0])?;
83            let low = decode_hex_nibble(pair[1])?;
84            Ok((high << 4) | low)
85        })
86        .collect()
87}
88
89const fn decode_hex_nibble(value: u8) -> Result<u8, icydb_schema::SchemaContractError> {
90    match value {
91        b'0'..=b'9' => Ok(value - b'0'),
92        b'a'..=b'f' => Ok(value - b'a' + 10),
93        b'A'..=b'F' => Ok(value - b'A' + 10),
94        _ => Err(icydb_schema::SchemaContractError::InvalidLiteral),
95    }
96}
97
98///
99/// Canister
100///
101
102#[derive(Clone, Debug, Serialize)]
103pub struct Canister {
104    def: Def,
105    memory_namespace: &'static str,
106    memory_min: u8,
107    memory_max: u8,
108    commit_memory_id: u8,
109    integrity_progress_memory_id: u8,
110    #[serde(skip)]
111    migration_plan: Option<MigrationPlanConstructor>,
112}
113
114impl Canister {
115    #[must_use]
116    pub const fn new(
117        def: Def,
118        memory_namespace: &'static str,
119        memory_min: u8,
120        memory_max: u8,
121        commit_memory_id: u8,
122        integrity_progress_memory_id: u8,
123        migration_plan: Option<MigrationPlanConstructor>,
124    ) -> Self {
125        Self {
126            def,
127            memory_namespace,
128            memory_min,
129            memory_max,
130            commit_memory_id,
131            integrity_progress_memory_id,
132            migration_plan,
133        }
134    }
135
136    #[must_use]
137    pub const fn def(&self) -> &Def {
138        &self.def
139    }
140
141    #[must_use]
142    pub const fn memory_namespace(&self) -> &'static str {
143        self.memory_namespace
144    }
145
146    #[must_use]
147    pub const fn memory_min(&self) -> u8 {
148        self.memory_min
149    }
150
151    #[must_use]
152    pub const fn memory_max(&self) -> u8 {
153        self.memory_max
154    }
155
156    #[must_use]
157    pub const fn commit_memory_id(&self) -> u8 {
158        self.commit_memory_id
159    }
160
161    #[must_use]
162    pub const fn integrity_progress_memory_id(&self) -> u8 {
163        self.integrity_progress_memory_id
164    }
165
166    /// Construct the optional source-declared migration plan.
167    ///
168    /// # Errors
169    ///
170    /// Returns the schema-contract error produced by the bounded declaration.
171    pub fn migration_plan(
172        &self,
173    ) -> Result<Option<icydb_schema::SchemaMigrationPlan>, icydb_schema::SchemaContractError> {
174        self.migration_plan
175            .map(|constructor| constructor())
176            .transpose()
177    }
178
179    #[must_use]
180    pub fn commit_stable_key(&self) -> String {
181        stable_memory_key(self.memory_namespace(), "commit", "control")
182    }
183
184    #[must_use]
185    pub fn integrity_progress_stable_key(&self) -> String {
186        stable_memory_key(self.memory_namespace(), "integrity", "progress")
187    }
188
189    fn validate_declared_memory_contract(&self, errs: &mut ErrorTree) {
190        validate_stable_key_segment(errs, "canister memory_namespace", self.memory_namespace());
191        validate_memory_id_in_range(
192            errs,
193            "commit_memory_id",
194            self.commit_memory_id(),
195            self.memory_min(),
196            self.memory_max(),
197        );
198        validate_app_memory_id(errs, "commit_memory_id", self.commit_memory_id());
199        validate_memory_id_not_reserved(errs, "commit_memory_id", self.commit_memory_id());
200        validate_stable_key(errs, "commit stable key", &self.commit_stable_key());
201        validate_memory_id_in_range(
202            errs,
203            "integrity_progress_memory_id",
204            self.integrity_progress_memory_id(),
205            self.memory_min(),
206            self.memory_max(),
207        );
208        validate_app_memory_id(
209            errs,
210            "integrity_progress_memory_id",
211            self.integrity_progress_memory_id(),
212        );
213        validate_memory_id_not_reserved(
214            errs,
215            "integrity_progress_memory_id",
216            self.integrity_progress_memory_id(),
217        );
218        validate_stable_key(
219            errs,
220            "integrity progress stable key",
221            &self.integrity_progress_stable_key(),
222        );
223    }
224
225    fn register_store_allocations(
226        &self,
227        canister_path: &str,
228        seen_ids: &mut BTreeMap<u8, (String, String)>,
229        seen_keys: &mut BTreeMap<String, (u8, String)>,
230        errs: &mut ErrorTree,
231    ) {
232        let schema = schema_read();
233        for (path, store) in schema.filter_nodes::<Store>(|node| node.canister() == canister_path) {
234            if !matches!(store.storage(), StoreStorage::Journaled(_)) {
235                continue;
236            }
237            for (allocation, role) in [
238                (
239                    store.stable_data_allocation(self.memory_namespace()),
240                    "data",
241                ),
242                (
243                    store.stable_index_allocation(self.memory_namespace()),
244                    "index",
245                ),
246                (
247                    store.stable_schema_allocation(self.memory_namespace()),
248                    "schema",
249                ),
250            ] {
251                assert_unique_memory_allocation(
252                    allocation.memory_id(),
253                    allocation.stable_key().to_string(),
254                    format!("Store `{path}`.{role}_memory"),
255                    canister_path,
256                    seen_ids,
257                    seen_keys,
258                    errs,
259                );
260            }
261            if store.is_journaled_storage() {
262                let allocation = store.journal_allocation(self.memory_namespace());
263                assert_unique_memory_allocation(
264                    allocation.memory_id(),
265                    allocation.stable_key().to_string(),
266                    format!("Store `{path}`.journal_memory"),
267                    canister_path,
268                    seen_ids,
269                    seen_keys,
270                    errs,
271                );
272            }
273        }
274    }
275}
276
277impl MacroNode for Canister {
278    fn as_any(&self) -> &dyn std::any::Any {
279        self
280    }
281}
282
283impl ValidateNode for Canister {
284    fn validate(&self) -> Result<(), ErrorTree> {
285        let mut errs = ErrorTree::new();
286
287        let canister_path = self.def().path();
288        let mut seen_ids = BTreeMap::<u8, (String, String)>::new();
289        let mut seen_keys = BTreeMap::<String, (u8, String)>::new();
290
291        self.validate_declared_memory_contract(&mut errs);
292
293        assert_unique_memory_allocation(
294            self.commit_memory_id(),
295            self.commit_stable_key(),
296            format!("Canister `{}`.commit_memory", self.def().path()),
297            &canister_path,
298            &mut seen_ids,
299            &mut seen_keys,
300            &mut errs,
301        );
302        assert_unique_memory_allocation(
303            self.integrity_progress_memory_id(),
304            self.integrity_progress_stable_key(),
305            format!("Canister `{}`.integrity_progress_memory", self.def().path()),
306            &canister_path,
307            &mut seen_ids,
308            &mut seen_keys,
309            &mut errs,
310        );
311        self.register_store_allocations(&canister_path, &mut seen_ids, &mut seen_keys, &mut errs);
312
313        errs.result()
314    }
315}
316
317fn assert_unique_memory_allocation(
318    memory_id: u8,
319    stable_key: String,
320    slot: String,
321    canister_path: &str,
322    seen_ids: &mut BTreeMap<u8, (String, String)>,
323    seen_keys: &mut BTreeMap<String, (u8, String)>,
324    errs: &mut ErrorTree,
325) {
326    if let Some((existing_key, existing_slot)) = seen_ids.get(&memory_id) {
327        err!(
328            errs,
329            "duplicate memory_id `{}` used in canister `{}`: {} ({}) conflicts with {} ({})",
330            memory_id,
331            canister_path,
332            existing_slot,
333            existing_key,
334            slot,
335            stable_key,
336        );
337    } else {
338        seen_ids.insert(memory_id, (stable_key.clone(), slot.clone()));
339    }
340
341    if let Some((existing_id, existing_slot)) = seen_keys.get(&stable_key) {
342        err!(
343            errs,
344            "duplicate stable_key `{}` used in canister `{}`: {} ({}) conflicts with {} ({})",
345            stable_key,
346            canister_path,
347            existing_slot,
348            existing_id,
349            slot,
350            memory_id,
351        );
352    } else {
353        seen_keys.insert(stable_key, (memory_id, slot));
354    }
355}
356
357impl VisitableNode for Canister {
358    fn route_key(&self) -> String {
359        self.def().path()
360    }
361}