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, U256, 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        "u256" => U256::from_str(value)
68            .map(ScalarLiteral::U256)
69            .map_err(|_| invalid()),
70        "ulid" => Ulid::from_str(value)
71            .map(ScalarLiteral::Ulid)
72            .map_err(|_| invalid()),
73        _ => Err(invalid()),
74    }
75}
76
77fn decode_migration_hex(value: &str) -> Result<Vec<u8>, icydb_schema::SchemaContractError> {
78    if !value.len().is_multiple_of(2) {
79        return Err(icydb_schema::SchemaContractError::InvalidLiteral);
80    }
81    value
82        .as_bytes()
83        .chunks_exact(2)
84        .map(|pair| {
85            let high = decode_hex_nibble(pair[0])?;
86            let low = decode_hex_nibble(pair[1])?;
87            Ok((high << 4) | low)
88        })
89        .collect()
90}
91
92const fn decode_hex_nibble(value: u8) -> Result<u8, icydb_schema::SchemaContractError> {
93    match value {
94        b'0'..=b'9' => Ok(value - b'0'),
95        b'a'..=b'f' => Ok(value - b'a' + 10),
96        b'A'..=b'F' => Ok(value - b'A' + 10),
97        _ => Err(icydb_schema::SchemaContractError::InvalidLiteral),
98    }
99}
100
101///
102/// Canister
103///
104
105#[derive(Clone, Debug, Serialize)]
106pub struct Canister {
107    def: Def,
108    memory_namespace: &'static str,
109    memory_min: u8,
110    memory_max: u8,
111    commit_memory_id: u8,
112    startup_memory_id: u8,
113    integrity_progress_memory_id: u8,
114    #[serde(skip)]
115    migration_plan: Option<MigrationPlanConstructor>,
116}
117
118impl Canister {
119    #[must_use]
120    #[expect(
121        clippy::too_many_arguments,
122        reason = "the generated canister node keeps its closed memory identities explicit"
123    )]
124    pub const fn new(
125        def: Def,
126        memory_namespace: &'static str,
127        memory_min: u8,
128        memory_max: u8,
129        commit_memory_id: u8,
130        startup_memory_id: u8,
131        integrity_progress_memory_id: u8,
132        migration_plan: Option<MigrationPlanConstructor>,
133    ) -> Self {
134        Self {
135            def,
136            memory_namespace,
137            memory_min,
138            memory_max,
139            commit_memory_id,
140            startup_memory_id,
141            integrity_progress_memory_id,
142            migration_plan,
143        }
144    }
145
146    #[must_use]
147    pub const fn def(&self) -> &Def {
148        &self.def
149    }
150
151    #[must_use]
152    pub const fn memory_namespace(&self) -> &'static str {
153        self.memory_namespace
154    }
155
156    #[must_use]
157    pub const fn memory_min(&self) -> u8 {
158        self.memory_min
159    }
160
161    #[must_use]
162    pub const fn memory_max(&self) -> u8 {
163        self.memory_max
164    }
165
166    #[must_use]
167    pub const fn commit_memory_id(&self) -> u8 {
168        self.commit_memory_id
169    }
170
171    #[must_use]
172    pub const fn startup_memory_id(&self) -> u8 {
173        self.startup_memory_id
174    }
175
176    #[must_use]
177    pub const fn integrity_progress_memory_id(&self) -> u8 {
178        self.integrity_progress_memory_id
179    }
180
181    /// Construct the optional source-declared migration plan.
182    ///
183    /// # Errors
184    ///
185    /// Returns the schema-contract error produced by the bounded declaration.
186    pub fn migration_plan(
187        &self,
188    ) -> Result<Option<icydb_schema::SchemaMigrationPlan>, icydb_schema::SchemaContractError> {
189        self.migration_plan
190            .map(|constructor| constructor())
191            .transpose()
192    }
193
194    #[must_use]
195    pub fn commit_stable_key(&self) -> String {
196        stable_memory_key(self.memory_namespace(), "commit", "control")
197    }
198
199    #[must_use]
200    pub fn integrity_progress_stable_key(&self) -> String {
201        stable_memory_key(self.memory_namespace(), "integrity", "progress")
202    }
203
204    #[must_use]
205    pub fn startup_stable_key(&self) -> String {
206        stable_memory_key(self.memory_namespace(), "startup", "control")
207    }
208
209    fn validate_declared_memory_contract(&self, errs: &mut ErrorTree) {
210        validate_stable_key_segment(errs, "canister memory_namespace", self.memory_namespace());
211        validate_memory_id_in_range(
212            errs,
213            "commit_memory_id",
214            self.commit_memory_id(),
215            self.memory_min(),
216            self.memory_max(),
217        );
218        validate_app_memory_id(errs, "commit_memory_id", self.commit_memory_id());
219        validate_memory_id_not_reserved(errs, "commit_memory_id", self.commit_memory_id());
220        validate_stable_key(errs, "commit stable key", &self.commit_stable_key());
221        validate_memory_id_in_range(
222            errs,
223            "startup_memory_id",
224            self.startup_memory_id(),
225            self.memory_min(),
226            self.memory_max(),
227        );
228        validate_app_memory_id(errs, "startup_memory_id", self.startup_memory_id());
229        validate_memory_id_not_reserved(errs, "startup_memory_id", self.startup_memory_id());
230        validate_stable_key(errs, "startup stable key", &self.startup_stable_key());
231        validate_memory_id_in_range(
232            errs,
233            "integrity_progress_memory_id",
234            self.integrity_progress_memory_id(),
235            self.memory_min(),
236            self.memory_max(),
237        );
238        validate_app_memory_id(
239            errs,
240            "integrity_progress_memory_id",
241            self.integrity_progress_memory_id(),
242        );
243        validate_memory_id_not_reserved(
244            errs,
245            "integrity_progress_memory_id",
246            self.integrity_progress_memory_id(),
247        );
248        validate_stable_key(
249            errs,
250            "integrity progress stable key",
251            &self.integrity_progress_stable_key(),
252        );
253    }
254
255    fn register_store_allocations(
256        &self,
257        canister_path: &str,
258        seen_ids: &mut BTreeMap<u8, (String, String)>,
259        seen_keys: &mut BTreeMap<String, (u8, String)>,
260        errs: &mut ErrorTree,
261    ) {
262        let schema = schema_read();
263        for (path, store) in schema.filter_nodes::<Store>(|node| node.canister() == canister_path) {
264            if !matches!(store.storage(), StoreStorage::Journaled(_)) {
265                continue;
266            }
267            for (allocation, role) in [
268                (
269                    store.stable_data_allocation(self.memory_namespace()),
270                    "data",
271                ),
272                (
273                    store.stable_index_allocation(self.memory_namespace()),
274                    "index",
275                ),
276                (
277                    store.stable_schema_allocation(self.memory_namespace()),
278                    "schema",
279                ),
280            ] {
281                assert_unique_memory_allocation(
282                    allocation.memory_id(),
283                    allocation.stable_key().to_string(),
284                    format!("Store `{path}`.{role}_memory"),
285                    canister_path,
286                    seen_ids,
287                    seen_keys,
288                    errs,
289                );
290            }
291            if store.is_journaled_storage() {
292                let allocation = store.journal_allocation(self.memory_namespace());
293                assert_unique_memory_allocation(
294                    allocation.memory_id(),
295                    allocation.stable_key().to_string(),
296                    format!("Store `{path}`.journal_memory"),
297                    canister_path,
298                    seen_ids,
299                    seen_keys,
300                    errs,
301                );
302            }
303        }
304    }
305}
306
307impl MacroNode for Canister {
308    fn as_any(&self) -> &dyn std::any::Any {
309        self
310    }
311}
312
313impl ValidateNode for Canister {
314    fn validate(&self) -> Result<(), ErrorTree> {
315        let mut errs = ErrorTree::new();
316
317        let canister_path = self.def().path();
318        let mut seen_ids = BTreeMap::<u8, (String, String)>::new();
319        let mut seen_keys = BTreeMap::<String, (u8, String)>::new();
320
321        self.validate_declared_memory_contract(&mut errs);
322
323        assert_unique_memory_allocation(
324            self.commit_memory_id(),
325            self.commit_stable_key(),
326            format!("Canister `{}`.commit_memory", self.def().path()),
327            &canister_path,
328            &mut seen_ids,
329            &mut seen_keys,
330            &mut errs,
331        );
332        assert_unique_memory_allocation(
333            self.startup_memory_id(),
334            self.startup_stable_key(),
335            format!("Canister `{}`.startup_memory", self.def().path()),
336            &canister_path,
337            &mut seen_ids,
338            &mut seen_keys,
339            &mut errs,
340        );
341        assert_unique_memory_allocation(
342            self.integrity_progress_memory_id(),
343            self.integrity_progress_stable_key(),
344            format!("Canister `{}`.integrity_progress_memory", self.def().path()),
345            &canister_path,
346            &mut seen_ids,
347            &mut seen_keys,
348            &mut errs,
349        );
350        self.register_store_allocations(&canister_path, &mut seen_ids, &mut seen_keys, &mut errs);
351
352        errs.result()
353    }
354}
355
356fn assert_unique_memory_allocation(
357    memory_id: u8,
358    stable_key: String,
359    slot: String,
360    canister_path: &str,
361    seen_ids: &mut BTreeMap<u8, (String, String)>,
362    seen_keys: &mut BTreeMap<String, (u8, String)>,
363    errs: &mut ErrorTree,
364) {
365    if let Some((existing_key, existing_slot)) = seen_ids.get(&memory_id) {
366        err!(
367            errs,
368            "duplicate memory_id `{}` used in canister `{}`: {} ({}) conflicts with {} ({})",
369            memory_id,
370            canister_path,
371            existing_slot,
372            existing_key,
373            slot,
374            stable_key,
375        );
376    } else {
377        seen_ids.insert(memory_id, (stable_key.clone(), slot.clone()));
378    }
379
380    if let Some((existing_id, existing_slot)) = seen_keys.get(&stable_key) {
381        err!(
382            errs,
383            "duplicate stable_key `{}` used in canister `{}`: {} ({}) conflicts with {} ({})",
384            stable_key,
385            canister_path,
386            existing_slot,
387            existing_id,
388            slot,
389            memory_id,
390        );
391    } else {
392        seen_keys.insert(stable_key, (memory_id, slot));
393    }
394}
395
396impl VisitableNode for Canister {
397    fn route_key(&self) -> String {
398        self.def().path()
399    }
400}