Skip to main content

ignition_core/webdev/
mod.rs

1//! Embedded WebDev route bundle — the CLI's own gateway-side surface.
2//!
3//! Phase 5 ships five action-dispatch WebDev routes (tags, tagConfig,
4//! alarms, tagHistory, scriptExec) whose sources live under
5//! `crates/ignition-core/webdev/routes/` (inside the crate so the
6//! published package embeds them). This module embeds them into the
7//! binary at compile time so `ign webdev deploy` (05-03) can zip and
8//! upload the bundle with no source checkout — the routes travel with
9//! the binary.
10//!
11//! Layering: this module is pure data (constants + [`include_str!`]); the
12//! deploy orchestration and the version handshake live in the actions
13//! layer. [`ROUTE_BUNDLE_VERSION`] must equal every route's `ROUTE_VERSION`
14//! constant and the `webdev/routes/VERSION` file — the contract tests
15//! below pin all three copies together.
16//!
17//! scriptExec is deliberately NOT part of [`ROUTE_FILES`]: its source is a
18//! TEMPLATE carrying the `__IGN_CLI_SECRET__` substitution marker
19//! ([`SCRIPT_EXEC_TEMPLATE`]). Deploy substitutes the deploy-time secret
20//! before packing it, and keeping it out of the always-on bundle makes an
21//! unsubstituted deploy impossible by construction.
22
23pub mod testing;
24
25/// Version of the embedded route bundle — the `version` handshake action
26/// in every route answers with this value (as `routeVersion`).
27pub const ROUTE_BUNDLE_VERSION: &str = "1.3.0";
28
29/// Minimum CLI version the deployed routes require (handshake `minCli`).
30pub const MIN_CLI: &str = "1.0";
31
32/// The always-on deploy set: `(zip_member_path, contents)` pairs.
33///
34/// Member paths are forward-slash zip paths in the Designer-native layout
35/// the gateway import expects (`project.json` at the root, route folders
36/// under `com.inductiveautomation.webdev/resources/cli/<route>/`). The 13
37/// members are `project.json` plus four route folders — tags, tagConfig,
38/// alarms, tagHistory — times three files each (`resource.json`,
39/// `config.json`, `doPost.py`).
40pub const ROUTE_FILES: &[(&str, &str)] = &[
41    // Deploy project manifest.
42    (
43        "project.json",
44        include_str!("../../webdev/routes/project.json"),
45    ),
46    // tags — live tag values (version/browse/read/write).
47    (
48        "com.inductiveautomation.webdev/resources/cli/tags/resource.json",
49        include_str!(
50            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/resource.json"
51        ),
52    ),
53    (
54        "com.inductiveautomation.webdev/resources/cli/tags/config.json",
55        include_str!(
56            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/config.json"
57        ),
58    ),
59    (
60        "com.inductiveautomation.webdev/resources/cli/tags/doPost.py",
61        include_str!(
62            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/doPost.py"
63        ),
64    ),
65    // tagConfig — configuration CRUD, UDTs, bulk export.
66    (
67        "com.inductiveautomation.webdev/resources/cli/tagConfig/resource.json",
68        include_str!(
69            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/resource.json"
70        ),
71    ),
72    (
73        "com.inductiveautomation.webdev/resources/cli/tagConfig/config.json",
74        include_str!(
75            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/config.json"
76        ),
77    ),
78    (
79        "com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py",
80        include_str!(
81            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py"
82        ),
83    ),
84    // alarms — active status, journal history, acknowledge.
85    (
86        "com.inductiveautomation.webdev/resources/cli/alarms/resource.json",
87        include_str!(
88            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/resource.json"
89        ),
90    ),
91    (
92        "com.inductiveautomation.webdev/resources/cli/alarms/config.json",
93        include_str!(
94            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/config.json"
95        ),
96    ),
97    (
98        "com.inductiveautomation.webdev/resources/cli/alarms/doPost.py",
99        include_str!(
100            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/doPost.py"
101        ),
102    ),
103    // tagHistory — historical tag value queries.
104    (
105        "com.inductiveautomation.webdev/resources/cli/tagHistory/resource.json",
106        include_str!(
107            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/resource.json"
108        ),
109    ),
110    (
111        "com.inductiveautomation.webdev/resources/cli/tagHistory/config.json",
112        include_str!(
113            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/config.json"
114        ),
115    ),
116    (
117        "com.inductiveautomation.webdev/resources/cli/tagHistory/doPost.py",
118        include_str!(
119            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/doPost.py"
120        ),
121    ),
122];
123
124/// The scriptExec route TEMPLATE — secret-gated arbitrary script execution.
125///
126/// Kept separate from [`ROUTE_FILES`] because deploy (05-03) must
127/// substitute the `__IGN_CLI_SECRET__` marker with the deploy-time hex
128/// secret BEFORE packing this member: shipping the template unsubstituted
129/// would arm the gate with a publicly-known placeholder value. The route
130/// itself fail-closes on exactly that state, and this separation is the
131/// structural guarantee it never happens.
132pub const SCRIPT_EXEC_TEMPLATE: &str = include_str!(
133    "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py"
134);
135
136/// The tagDelete route TEMPLATE (26-17, D-13): the authenticated,
137/// scope-enforcing `system.tag.deleteTags` route. Outside [`ROUTE_FILES`]
138/// for the scriptExec reason: the install substitutes the
139/// `__IGN_TAG_DELETE_SECRET__` and `__IGN_TAG_DELETE_PROVIDERS__` markers
140/// before packing, and the route refuses every action while either is
141/// unsubstituted.
142pub const TAG_DELETE_TEMPLATE: &str = include_str!(
143    "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagDelete/doPost.py"
144);
145
146/// The tagDelete `config.json` TEMPLATE: `require-auth: true` with the
147/// `__IGN_TAG_DELETE_ROLE__` and `__IGN_TAG_DELETE_USER_SOURCE__` markers in
148/// `required-roles` and `user-source`, set through serde_json at install.
149pub const TAG_DELETE_CONFIG_TEMPLATE: &str = include_str!(
150    "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagDelete/config.json"
151);
152
153/// The tagDelete `resource.json` (no markers).
154pub const TAG_DELETE_RESOURCE_JSON: &str = include_str!(
155    "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagDelete/resource.json"
156);
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    // The repo-level VERSION file — the third copy of the handshake
163    // version, pinned here so all three must move together.
164    const VERSION_FILE: &str = include_str!("../../webdev/routes/VERSION");
165
166    /// (1) Every always-on doPost.py carries the handshake constants, and
167    /// they match the Rust embed — route sources and binary must never
168    /// drift. (String-containment: Jython isn't parseable here.)
169    #[test]
170    fn route_sources_carry_the_embedded_handshake_constants() {
171        let route_version = format!("ROUTE_VERSION = '{}'", ROUTE_BUNDLE_VERSION);
172        let min_cli = format!("MIN_CLI = '{}'", MIN_CLI);
173        let mut do_post_count = 0;
174        for (name, contents) in ROUTE_FILES {
175            if name.ends_with("doPost.py") {
176                do_post_count += 1;
177                assert!(
178                    contents.contains(&route_version),
179                    "{name}: missing {route_version}"
180                );
181                assert!(contents.contains(&min_cli), "{name}: missing {min_cli}");
182            }
183        }
184        assert_eq!(do_post_count, 4, "expected the four always-on dispatchers");
185        assert_eq!(
186            VERSION_FILE.trim(),
187            ROUTE_BUNDLE_VERSION,
188            "webdev/routes/VERSION drifted from ROUTE_BUNDLE_VERSION"
189        );
190    }
191
192    /// (2) The always-on bundle must carry NO secret placeholder — the
193    /// marker exists only in the scriptExec template.
194    #[test]
195    fn always_on_bundle_carries_no_secret_placeholder() {
196        for (name, contents) in ROUTE_FILES {
197            assert!(
198                !contents.contains("__IGN_CLI_SECRET__"),
199                "{name}: the secret placeholder must not ship in the always-on bundle"
200            );
201        }
202    }
203
204    /// (3) The scriptExec template is substitutable exactly once and
205    /// fail-closed by default.
206    #[test]
207    fn script_exec_template_is_substitutable_and_fail_closed() {
208        assert_eq!(
209            SCRIPT_EXEC_TEMPLATE.matches("__IGN_CLI_SECRET__").count(),
210            1,
211            "the deploy substitution needs exactly one marker occurrence"
212        );
213        assert!(
214            SCRIPT_EXEC_TEMPLATE.contains("SECRET = None"),
215            "the template must keep its fail-closed SECRET default"
216        );
217    }
218
219    /// (4) Zip member names use forward slashes only — a backslash would
220    /// produce a broken member on every non-Windows zip reader and a
221    /// differently-named one on Windows.
222    #[test]
223    fn member_names_use_forward_slashes_only() {
224        for (name, _) in ROUTE_FILES {
225            assert!(!name.contains('\\'), "backslash in member name: {name}");
226        }
227    }
228
229    /// (5) The manifest is exactly the 13-member always-on set: one
230    /// project.json plus four route folders × three files, all under the
231    /// Designer-native route root.
232    #[test]
233    fn manifest_lists_exactly_thirteen_members() {
234        assert_eq!(ROUTE_FILES.len(), 13);
235        assert_eq!(
236            ROUTE_FILES
237                .iter()
238                .filter(|(name, _)| *name == "project.json")
239                .count(),
240            1
241        );
242        assert_eq!(
243            ROUTE_FILES
244                .iter()
245                .filter(|(name, _)| name.ends_with("doPost.py"))
246                .count(),
247            4
248        );
249        for (name, _) in ROUTE_FILES {
250            assert!(
251                *name == "project.json"
252                    || name.starts_with("com.inductiveautomation.webdev/resources/cli/"),
253                "member outside the Designer-native layout: {name}"
254            );
255        }
256    }
257
258    /// (7) The tagDelete templates (26-17): each install marker exactly
259    /// once in its file, the fail-closed defaults kept, and require-auth
260    /// on in the config template.
261    #[test]
262    fn tag_delete_templates_carry_each_marker_once() {
263        for marker in ["__IGN_TAG_DELETE_SECRET__", "__IGN_TAG_DELETE_PROVIDERS__"] {
264            assert_eq!(
265                TAG_DELETE_TEMPLATE.matches(marker).count(),
266                1,
267                "doPost.py: {marker}"
268            );
269            assert_eq!(TAG_DELETE_CONFIG_TEMPLATE.matches(marker).count(), 0);
270        }
271        for marker in ["__IGN_TAG_DELETE_ROLE__", "__IGN_TAG_DELETE_USER_SOURCE__"] {
272            assert_eq!(
273                TAG_DELETE_CONFIG_TEMPLATE.matches(marker).count(),
274                1,
275                "config.json: {marker}"
276            );
277            assert_eq!(TAG_DELETE_TEMPLATE.matches(marker).count(), 0);
278        }
279        assert!(TAG_DELETE_TEMPLATE.contains("SECRET = None or '__IGN_TAG_DELETE_SECRET__'"));
280        assert!(
281            TAG_DELETE_TEMPLATE.contains("PROVIDERS_JSON = None or '__IGN_TAG_DELETE_PROVIDERS__'")
282        );
283        let config: serde_json::Value =
284            serde_json::from_str(TAG_DELETE_CONFIG_TEMPLATE).expect("config template parses");
285        assert_eq!(config["doPost"]["require-auth"], true);
286        let resource: serde_json::Value =
287            serde_json::from_str(TAG_DELETE_RESOURCE_JSON).expect("resource.json parses");
288        assert_eq!(
289            resource["files"],
290            serde_json::json!(["config.json", "doPost.py"])
291        );
292    }
293
294    /// (8) The tagDelete doPost carries the bundle handshake constants,
295    /// calls `system.tag.deleteTags` exactly once and holds no other
296    /// write or code-execution call.
297    #[test]
298    fn tag_delete_template_has_one_write_call() {
299        assert!(
300            TAG_DELETE_TEMPLATE.contains(&format!("ROUTE_VERSION = '{}'", ROUTE_BUNDLE_VERSION))
301        );
302        assert!(TAG_DELETE_TEMPLATE.contains(&format!("MIN_CLI = '{}'", MIN_CLI)));
303        assert_eq!(
304            TAG_DELETE_TEMPLATE.matches("system.tag.deleteTags").count(),
305            1
306        );
307        for forbidden in [
308            "system.tag.configure",
309            "importTags",
310            "writeBlocking",
311            "writeAsync",
312            "exec ",
313            "eval(",
314        ] {
315            assert!(
316                !TAG_DELETE_TEMPLATE.contains(forbidden),
317                "tagDelete doPost.py must not hold {forbidden:?}"
318            );
319        }
320        // The secret rides one header only, never Authorization.
321        assert!(!TAG_DELETE_TEMPLATE.contains("Bearer"));
322    }
323
324    /// (8b) 26-REVIEW-D WR-D1: the tagDelete doPost writes an audit line
325    /// before the one `deleteTags` call and another in a `finally` after it,
326    /// so a raise still leaves a record; an `exists` read that raises makes
327    /// that path's `existsAfter` null (not confirmed) instead of turning the
328    /// whole answer into `route_error`. Jython 2.7: no f-strings.
329    #[test]
330    fn tag_delete_template_audits_around_the_write() {
331        let source = TAG_DELETE_TEMPLATE;
332        let at = |needle: &str| {
333            source
334                .find(needle)
335                .unwrap_or_else(|| panic!("doPost.py lacks {needle:?}"))
336        };
337        let begin = at("u'delete-begin ");
338        let write = at("system.tag.deleteTags(");
339        let finally = at("finally:");
340        let end = at("u'delete-end ");
341        assert!(begin < write, "the begin line precedes the write");
342        assert!(
343            write < finally && finally < end,
344            "the end line is in a finally"
345        );
346        assert!(
347            !source.contains("'existsAfter': bool(system.tag.exists("),
348            "an exists read must not raise out of the answer"
349        );
350        assert!(
351            source.contains("existsAfter = None"),
352            "a failed read is null"
353        );
354        let lines: Vec<&str> = source.lines().collect();
355        for (index, line) in lines.iter().enumerate() {
356            let bytes = line.as_bytes();
357            for i in 1..bytes.len().saturating_sub(1) {
358                let starts =
359                    i == 1 || !(bytes[i - 2].is_ascii_alphanumeric() || bytes[i - 2] == b'_');
360                assert!(
361                    !(bytes[i - 1] == b'f' && (bytes[i] == b'\'' || bytes[i] == b'"') && starts),
362                    "doPost.py line {}: an f-string is not Jython 2.7: {line}",
363                    index + 1
364                );
365            }
366        }
367    }
368
369    /// (9) The always-on bundle carries no tagDelete marker or member.
370    #[test]
371    fn always_on_bundle_carries_no_tag_delete_marker() {
372        for (name, contents) in ROUTE_FILES {
373            assert!(
374                !contents.contains("__IGN_TAG_DELETE_"),
375                "{name}: a tagDelete marker must not ship in the always-on bundle"
376            );
377            assert!(!name.contains("tagDelete"), "{name}");
378        }
379    }
380
381    /// (6) The tagConfig route source keeps its provider-ROOT refusal
382    /// (07-06): the pre-call bracket detection + RpcContext
383    /// translation both refuse `provider_root_unsupported` —
384    /// wiremock cannot execute the route's Python, so this source
385    /// pin is the route-side regression guard (alongside the
386    /// Rust-side denial mapping contract).
387    #[test]
388    fn tagconfig_route_source_refuses_provider_roots() {
389        let (_, source) = ROUTE_FILES
390            .iter()
391            .find(|(name, _)| {
392                *name == "com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py"
393            })
394            .expect("tagConfig doPost.py in the manifest");
395        assert!(
396            source.contains("provider_root_unsupported"),
397            "the tagConfig route must keep its provider-root refusal"
398        );
399        assert!(
400            source.contains("def is_provider_root("),
401            "the bracket-form detector must stay nested inside doPost (byte-0 rule)"
402        );
403        assert!(
404            source.contains("'No RpcContext' in traceback.format_exc()"),
405            "the bare-form RpcContext translation must stay"
406        );
407    }
408}