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