Skip to main content

polyspec_crudui_validator/
forbidden_scan.rs

1//! Recursive forbidden meta-key scan (SPEC §6) — the runtime half of the
2//! global rejection that the meta-schema's `propertyNames` enforces statically.
3//!
4//! R1: the types/parser PRESERVE every key of the open buckets (round-trip), so
5//! blocking forbidden meta keys is the VALIDATION layer's job, not the model's.
6//! The typed models (`FieldSpec` + `ExtraMap`) only reject forbidden keys at the
7//! top level, as unknown keys inside the closed buckets, and one level under open
8//! buckets — a deeply nested meta key (`validate.required.if`,
9//! `options.x.display_switch`, …) leaks through. This scan closes that leak: it
10//! walks the COMPOSED single spec (after `$ref`/`$patch` expansion and x-strip)
11//! to ARBITRARY depth and rejects a forbidden key found at ANY depth — including
12//! one level under a slot/bucket body.
13//!
14//! Placement (SPEC §2 pipeline): this runs in the spec LOAD path, immediately
15//! after compose expansion and before validation entry. A hit is therefore a LOAD
16//! failure (`ComposeLoadError`, code `FORBIDDEN_META_KEY`) — the spec never comes
17//! into existence — never a `valid:false` validation result.
18//!
19//! Forbidden set (SPEC §6): the enumerated `FORBIDDEN_META_KEYS`
20//! (condition-only / retired / magic-symbol meta keys) PLUS the `x{key}` comment
21//! family (any `x`-prefixed key). `$ref`/`$patch` are NOT forbidden — compose
22//! already consumed them, so they do not survive to here; `x{key}` IS strip-
23//! eligible, so any `x{key}` that survives to this scan is rejected (the strip
24//! belongs to the meta-schema; survival means it was not stripped).
25//!
26//! Byte-for-byte with the JS reference (`packages/validator/ts/src/
27//! forbidden-scan.ts`); the shared 4-language fixture
28//! `tests/fixtures/spec-validity/cases.json` is the single source of truth.
29
30use serde_json::Value;
31
32use crate::compose::{ComposeErrorCode, ComposeLoadError};
33use crate::types::FORBIDDEN_META_KEYS;
34
35/// Whether `key` is an `x{key}` comment key: an `x` followed by at least one more
36/// character (`xclass`, `xstyle`, `xnote`, …). The bare key `x` is not a comment.
37/// The authoritative strip belongs to the meta-schema; this is the runtime
38/// backstop that rejects an `x{key}` that survived.
39fn is_x_comment_key(key: &str) -> bool {
40    key.len() > 1 && key.as_bytes()[0] == b'x'
41}
42
43/// Whether `key` is globally forbidden (enumerated literal OR `x{key}`).
44fn is_forbidden_key(key: &str) -> bool {
45    FORBIDDEN_META_KEYS.contains(&key) || is_x_comment_key(key)
46}
47
48/// Recursively scan a composed single spec for any forbidden meta key at any
49/// depth. Returns `Err(ComposeLoadError { code: FORBIDDEN_META_KEY })` on the
50/// first hit, with the dotted path to the offending key in `message` and `trace`.
51///
52/// The scan descends into every object value AND every array element (a forbidden
53/// key nested inside an array of sub-specs is caught too). Map keys are checked
54/// before descending into their values, so the reported path points at the
55/// shallowest offending key.
56///
57/// `root_path` is the path prefix for the error trace; the load path seeds it
58/// with `["properties"]` so a hit reports `properties.<field>.…` (4-language
59/// path convention).
60pub fn scan_forbidden_keys(spec: &Value, root_path: &[String]) -> Result<(), ComposeLoadError> {
61    walk(spec, root_path.to_vec())
62}
63
64fn walk(node: &Value, path: Vec<String>) -> Result<(), ComposeLoadError> {
65    match node {
66        Value::Array(items) => {
67            for (i, item) in items.iter().enumerate() {
68                let mut child = path.clone();
69                child.push(i.to_string());
70                walk(item, child)?;
71            }
72            Ok(())
73        }
74        Value::Object(map) => {
75            // Check every key at THIS level first (shallowest hit reported),
76            // then descend — matching the JS two-pass key walk.
77            for key in map.keys() {
78                if is_forbidden_key(key) {
79                    let mut at = path.clone();
80                    at.push(key.clone());
81                    return Err(ComposeLoadError::with_trace(
82                        ComposeErrorCode::ForbiddenMetaKey,
83                        format!("forbidden meta key \"{key}\" at {}", at.join(".")),
84                        at,
85                    ));
86                }
87            }
88            for (key, value) in map {
89                let mut child = path.clone();
90                child.push(key.clone());
91                walk(value, child)?;
92            }
93            Ok(())
94        }
95        _ => Ok(()),
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use serde_json::json;
103
104    fn scan(v: &Value) -> Result<(), ComposeLoadError> {
105        scan_forbidden_keys(v, &["properties".to_string()])
106    }
107
108    #[test]
109    fn clean_spec_passes() {
110        let v = json!({
111            "field": { "type": "text", "validate": { "required": true } }
112        });
113        assert!(scan(&v).is_ok());
114    }
115
116    #[test]
117    fn true_default_key_is_not_forbidden() {
118        let v = json!({
119            "field": { "type": "number", "validate": { "max": { ".tier == 'pro'": 1000, "true": 10 } } }
120        });
121        assert!(scan(&v).is_ok());
122    }
123
124    #[test]
125    fn deeply_nested_forbidden_key_is_caught_with_path() {
126        let v = json!({
127            "field": { "type": "text", "validate": { "required": { "if": ".x" } } }
128        });
129        let err = scan(&v).unwrap_err();
130        assert_eq!(err.code, ComposeErrorCode::ForbiddenMetaKey);
131        assert_eq!(err.trace.join("."), "properties.field.validate.required.if");
132    }
133
134    #[test]
135    fn x_comment_survivor_is_rejected_but_bare_x_object_value_is_not_a_key() {
136        // `xnote` (x{key}) is rejected.
137        let v = json!({ "field": { "type": "text", "xnote": "c" } });
138        assert_eq!(
139            scan(&v).unwrap_err().trace.join("."),
140            "properties.field.xnote"
141        );
142    }
143
144    #[test]
145    fn forbidden_key_inside_array_element_is_caught() {
146        let v = json!({
147            "field": { "type": "select", "options": {
148                "items": [ { "value": "a" }, { "value": "b", "display_target": ".x" } ]
149            } }
150        });
151        assert_eq!(
152            scan(&v).unwrap_err().trace.join("."),
153            "properties.field.options.items.1.display_target"
154        );
155    }
156
157    #[test]
158    fn shallowest_key_reported_when_multiple_present() {
159        // Two forbidden keys at the same level: declaration order picks the first.
160        let v = json!({ "field": { "if": 1, "when": 2 } });
161        let err = scan(&v).unwrap_err();
162        assert_eq!(err.trace.join("."), "properties.field.if");
163    }
164}