Skip to main content

icydb_schema/node/
mod.rs

1//! Schema node graph for validated canister/entity/type definitions.
2//!
3//! This module owns the typed node descriptors used by schema validation,
4//! derive code generation, and visitor traversal.
5
6mod arg;
7mod canister;
8mod def;
9mod entity;
10mod r#enum;
11mod field;
12mod index;
13mod item;
14mod list;
15mod map;
16mod newtype;
17mod primary_key;
18mod record;
19mod relation;
20mod sanitizer;
21mod schema;
22mod set;
23mod store;
24mod tuple;
25mod r#type;
26mod validator;
27mod value;
28
29use crate::{
30    prelude::*,
31    visit::{Event, Visitor},
32};
33use std::any::Any;
34use thiserror::Error as ThisError;
35
36pub use arg::*;
37pub use canister::*;
38pub use def::*;
39pub use entity::*;
40pub use r#enum::*;
41pub use field::*;
42pub use index::*;
43pub use item::*;
44pub use list::*;
45pub use map::*;
46pub use newtype::*;
47pub use primary_key::*;
48pub use record::*;
49pub use relation::*;
50pub use sanitizer::*;
51pub use schema::*;
52pub use set::*;
53pub use store::*;
54pub use tuple::*;
55pub use r#type::*;
56pub use validator::*;
57pub use value::*;
58
59pub const APP_MEMORY_ID_MIN: u8 = 100;
60pub const APP_MEMORY_ID_MAX: u8 = 254;
61const RESERVED_INTERNAL_MEMORY_ID: u8 = u8::MAX;
62
63///
64/// NodeError
65///
66/// Error raised when schema-node lookup or downcasting crosses an invalid
67/// boundary.
68///
69
70#[derive(Debug, ThisError)]
71pub enum NodeError {
72    #[error("{0} is an incorrect node type")]
73    IncorrectNodeType(String),
74
75    #[error("path not found: {0}")]
76    PathNotFound(String),
77}
78
79///
80/// NODE TRAITS
81///
82
83///
84/// MacroNode
85///
86/// Shared trait implemented by every concrete schema node descriptor.
87/// `as_any` keeps type erasure and downcasting local to the schema-node
88/// boundary instead of leaking it into callers.
89///
90
91pub(crate) trait MacroNode: Any {
92    fn as_any(&self) -> &dyn Any;
93}
94
95///
96/// ValidateNode
97///
98/// Trait implemented by schema nodes that validate local invariants against
99/// the surrounding schema graph.
100///
101
102pub(crate) trait ValidateNode {
103    fn validate(&self) -> Result<(), ErrorTree> {
104        Ok(())
105    }
106}
107
108///
109/// VisitableNode
110///
111/// Trait implemented by schema nodes that participate in recursive visitor
112/// traversal with canonical route-key ordering.
113///
114
115pub(crate) trait VisitableNode: ValidateNode {
116    // Route key contributes one node-local path segment to the visitor path.
117    fn route_key(&self) -> String {
118        String::new()
119    }
120
121    // Drive the enter/children/exit visitor sequence for this node.
122    fn accept<V: Visitor>(&self, visitor: &mut V) {
123        visitor.push(&self.route_key());
124        visitor.visit(self, Event::Enter);
125        self.drive(visitor);
126        visitor.visit(self, Event::Exit);
127        visitor.pop();
128    }
129
130    // Visit child nodes in canonical order.
131    fn drive<V: Visitor>(&self, _: &mut V) {}
132}
133
134// Validate one memory id against the declared canister range.
135pub(crate) fn validate_memory_id_in_range(
136    errs: &mut ErrorTree,
137    label: &str,
138    memory_id: u8,
139    min: u8,
140    max: u8,
141) {
142    if !memory_id_is_in_range(memory_id, min, max) {
143        err!(errs, "{label} {memory_id} outside of range {min}-{max}");
144    }
145}
146
147// Reject memory id values reserved by stable-structures internals.
148pub(crate) fn validate_memory_id_not_reserved(errs: &mut ErrorTree, label: &str, memory_id: u8) {
149    if memory_id_is_reserved(memory_id) {
150        err!(
151            errs,
152            "{label} {memory_id} is reserved for stable-structures internals",
153        );
154    }
155}
156
157// Validate one application-owned memory id against IcyDB's generated-store range.
158pub(crate) fn validate_app_memory_id(errs: &mut ErrorTree, label: &str, memory_id: u8) {
159    if !app_memory_id_is_valid(memory_id) {
160        err!(
161            errs,
162            "{label} {memory_id} outside of application memory range {APP_MEMORY_ID_MIN}-{APP_MEMORY_ID_MAX}",
163        );
164    }
165}
166
167#[must_use]
168pub const fn memory_id_is_in_range(memory_id: u8, min: u8, max: u8) -> bool {
169    memory_id >= min && memory_id <= max
170}
171
172#[must_use]
173pub const fn memory_id_is_reserved(memory_id: u8) -> bool {
174    memory_id == RESERVED_INTERNAL_MEMORY_ID
175}
176
177#[must_use]
178pub const fn app_memory_id_is_valid(memory_id: u8) -> bool {
179    memory_id >= APP_MEMORY_ID_MIN && memory_id <= APP_MEMORY_ID_MAX
180}
181
182pub(crate) fn validate_stable_key_segment(errs: &mut ErrorTree, label: &str, value: &str) {
183    if !stable_key_segment_is_canonical(value) {
184        err!(
185            errs,
186            "{label} `{value}` must use lowercase ASCII letters, digits, and underscores",
187        );
188    }
189}
190
191pub(crate) fn validate_stable_key(errs: &mut ErrorTree, label: &str, value: &str) {
192    if !stable_key_is_canonical(value) {
193        err!(
194            errs,
195            "{label} `{value}` must be canonical lowercase ASCII, must use dots as separators, must use underscores instead of hyphens, must end in .v1, and must not start with canic.",
196        );
197    }
198}
199
200#[must_use]
201pub fn stable_key_segment_is_canonical(value: &str) -> bool {
202    !value.is_empty()
203        && value
204            .bytes()
205            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
206}
207
208#[must_use]
209pub(crate) fn stable_key_is_canonical(value: &str) -> bool {
210    if value.starts_with("canic.") {
211        return false;
212    }
213
214    let mut saw_segment = false;
215    let mut last_segment = "";
216    for segment in value.split('.') {
217        if !stable_key_segment_is_canonical(segment) {
218            return false;
219        }
220        saw_segment = true;
221        last_segment = segment;
222    }
223
224    saw_segment && last_segment == "v1"
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn app_memory_id_policy_accepts_only_application_range() {
233        for memory_id in APP_MEMORY_ID_MIN..=APP_MEMORY_ID_MAX {
234            let mut errors = ErrorTree::new();
235            validate_app_memory_id(&mut errors, "memory_id", memory_id);
236            validate_memory_id_not_reserved(&mut errors, "memory_id", memory_id);
237            assert!(
238                errors.is_empty(),
239                "schema should accept app memory id {memory_id}: {errors}",
240            );
241        }
242
243        for memory_id in [0, APP_MEMORY_ID_MIN - 1] {
244            let mut errors = ErrorTree::new();
245            validate_app_memory_id(&mut errors, "memory_id", memory_id);
246            assert!(
247                !errors.is_empty(),
248                "schema should reject below-range app memory id {memory_id}",
249            );
250        }
251
252        let mut errors = ErrorTree::new();
253        validate_app_memory_id(&mut errors, "memory_id", u8::MAX);
254        validate_memory_id_not_reserved(&mut errors, "memory_id", u8::MAX);
255        let rendered = errors.to_string();
256        assert!(
257            rendered.contains("outside of application memory range 100-254"),
258            "reserved id should also fail the app range check: {rendered}",
259        );
260        assert!(
261            rendered.contains("reserved for stable-structures internals"),
262            "reserved id should fail closed explicitly: {rendered}",
263        );
264    }
265
266    #[test]
267    fn stable_key_segment_policy_is_canonical_ascii_only() {
268        for segment in ["db", "demo_rpg", "store_1", "v1"] {
269            assert!(stable_key_segment_is_canonical(segment));
270        }
271
272        for segment in ["", "Demo", "demo-rpg", "demo.rpg", "canic.owned"] {
273            assert!(!stable_key_segment_is_canonical(segment));
274        }
275    }
276
277    #[test]
278    fn full_stable_key_policy_rejects_reserved_and_malformed_keys() {
279        assert!(stable_key_is_canonical("icydb.demo_rpg.characters.data.v1"));
280
281        for key in [
282            "canic.demo_rpg.characters.data.v1",
283            "icydb.demo_rpg.characters.data",
284            "icydb.demo-rpg.characters.data.v1",
285            "icydb.demo_rpg..data.v1",
286            "icydb.Demo.characters.data.v1",
287            "icydb.demo_rpg.characters.data.v2",
288        ] {
289            assert!(!stable_key_is_canonical(key), "key should fail: {key}");
290        }
291    }
292}