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 at most 128 bytes, must use lowercase ASCII segments beginning with a letter, 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    let mut bytes = value.bytes();
203    bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
204        && bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
205}
206
207#[must_use]
208pub(crate) fn stable_key_is_canonical(value: &str) -> bool {
209    if value.len() > 128 || value.starts_with("canic.") {
210        return false;
211    }
212
213    let mut saw_segment = false;
214    let mut last_segment = "";
215    for segment in value.split('.') {
216        if !stable_key_segment_is_canonical(segment) {
217            return false;
218        }
219        saw_segment = true;
220        last_segment = segment;
221    }
222
223    saw_segment && last_segment == "v1"
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn app_memory_id_policy_accepts_only_application_range() {
232        for memory_id in APP_MEMORY_ID_MIN..=APP_MEMORY_ID_MAX {
233            let mut errors = ErrorTree::new();
234            validate_app_memory_id(&mut errors, "memory_id", memory_id);
235            validate_memory_id_not_reserved(&mut errors, "memory_id", memory_id);
236            assert!(
237                errors.is_empty(),
238                "schema should accept app memory id {memory_id}: {errors}",
239            );
240        }
241
242        for memory_id in [0, APP_MEMORY_ID_MIN - 1] {
243            let mut errors = ErrorTree::new();
244            validate_app_memory_id(&mut errors, "memory_id", memory_id);
245            assert!(
246                !errors.is_empty(),
247                "schema should reject below-range app memory id {memory_id}",
248            );
249        }
250
251        let mut errors = ErrorTree::new();
252        validate_app_memory_id(&mut errors, "memory_id", u8::MAX);
253        validate_memory_id_not_reserved(&mut errors, "memory_id", u8::MAX);
254        let rendered = errors.to_string();
255        assert!(
256            rendered.contains("outside of application memory range 100-254"),
257            "reserved id should also fail the app range check: {rendered}",
258        );
259        assert!(
260            rendered.contains("reserved for stable-structures internals"),
261            "reserved id should fail closed explicitly: {rendered}",
262        );
263    }
264
265    #[test]
266    fn stable_key_segment_policy_requires_a_lowercase_letter_prefix() {
267        for segment in ["db", "demo_rpg", "store_1", "v1"] {
268            assert!(stable_key_segment_is_canonical(segment));
269        }
270
271        for segment in [
272            "",
273            "1db",
274            "_db",
275            "Demo",
276            "demo-rpg",
277            "demo.rpg",
278            "canic.owned",
279        ] {
280            assert!(!stable_key_segment_is_canonical(segment));
281        }
282    }
283
284    #[test]
285    fn full_stable_key_policy_rejects_reserved_and_malformed_keys() {
286        assert!(stable_key_is_canonical("icydb.demo_rpg.characters.data.v1"));
287
288        for key in [
289            "canic.demo_rpg.characters.data.v1",
290            "icydb.demo_rpg.characters.data",
291            "icydb.demo-rpg.characters.data.v1",
292            "icydb.demo_rpg..data.v1",
293            "icydb.Demo.characters.data.v1",
294            "icydb.demo_rpg.characters.data.v2",
295            "icydb.1demo.characters.data.v1",
296            "icydb._demo.characters.data.v1",
297        ] {
298            assert!(!stable_key_is_canonical(key), "key should fail: {key}");
299        }
300
301        let maximum = format!("icydb.{}.data.v1", "a".repeat(114));
302        assert_eq!(maximum.len(), 128);
303        assert!(stable_key_is_canonical(&maximum));
304
305        let oversized = format!("icydb.{}.data.v1", "a".repeat(115));
306        assert_eq!(oversized.len(), 129);
307        assert!(!stable_key_is_canonical(&oversized));
308    }
309}