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