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#[cfg(test)]
137mod tests {
138    use super::*;
139
140    // The repo-level VERSION file — the third copy of the handshake
141    // version, pinned here so all three must move together.
142    const VERSION_FILE: &str = include_str!("../../webdev/routes/VERSION");
143
144    /// (1) Every always-on doPost.py carries the handshake constants, and
145    /// they match the Rust embed — route sources and binary must never
146    /// drift. (String-containment: Jython isn't parseable here.)
147    #[test]
148    fn route_sources_carry_the_embedded_handshake_constants() {
149        let route_version = format!("ROUTE_VERSION = '{}'", ROUTE_BUNDLE_VERSION);
150        let min_cli = format!("MIN_CLI = '{}'", MIN_CLI);
151        let mut do_post_count = 0;
152        for (name, contents) in ROUTE_FILES {
153            if name.ends_with("doPost.py") {
154                do_post_count += 1;
155                assert!(
156                    contents.contains(&route_version),
157                    "{name}: missing {route_version}"
158                );
159                assert!(contents.contains(&min_cli), "{name}: missing {min_cli}");
160            }
161        }
162        assert_eq!(do_post_count, 4, "expected the four always-on dispatchers");
163        assert_eq!(
164            VERSION_FILE.trim(),
165            ROUTE_BUNDLE_VERSION,
166            "webdev/routes/VERSION drifted from ROUTE_BUNDLE_VERSION"
167        );
168    }
169
170    /// (2) The always-on bundle must carry NO secret placeholder — the
171    /// marker exists only in the scriptExec template.
172    #[test]
173    fn always_on_bundle_carries_no_secret_placeholder() {
174        for (name, contents) in ROUTE_FILES {
175            assert!(
176                !contents.contains("__IGN_CLI_SECRET__"),
177                "{name}: the secret placeholder must not ship in the always-on bundle"
178            );
179        }
180    }
181
182    /// (3) The scriptExec template is substitutable exactly once and
183    /// fail-closed by default.
184    #[test]
185    fn script_exec_template_is_substitutable_and_fail_closed() {
186        assert_eq!(
187            SCRIPT_EXEC_TEMPLATE.matches("__IGN_CLI_SECRET__").count(),
188            1,
189            "the deploy substitution needs exactly one marker occurrence"
190        );
191        assert!(
192            SCRIPT_EXEC_TEMPLATE.contains("SECRET = None"),
193            "the template must keep its fail-closed SECRET default"
194        );
195    }
196
197    /// (4) Zip member names use forward slashes only — a backslash would
198    /// produce a broken member on every non-Windows zip reader and a
199    /// differently-named one on Windows.
200    #[test]
201    fn member_names_use_forward_slashes_only() {
202        for (name, _) in ROUTE_FILES {
203            assert!(!name.contains('\\'), "backslash in member name: {name}");
204        }
205    }
206
207    /// (5) The manifest is exactly the 13-member always-on set: one
208    /// project.json plus four route folders × three files, all under the
209    /// Designer-native route root.
210    #[test]
211    fn manifest_lists_exactly_thirteen_members() {
212        assert_eq!(ROUTE_FILES.len(), 13);
213        assert_eq!(
214            ROUTE_FILES
215                .iter()
216                .filter(|(name, _)| *name == "project.json")
217                .count(),
218            1
219        );
220        assert_eq!(
221            ROUTE_FILES
222                .iter()
223                .filter(|(name, _)| name.ends_with("doPost.py"))
224                .count(),
225            4
226        );
227        for (name, _) in ROUTE_FILES {
228            assert!(
229                *name == "project.json"
230                    || name.starts_with("com.inductiveautomation.webdev/resources/cli/"),
231                "member outside the Designer-native layout: {name}"
232            );
233        }
234    }
235
236    /// (6) The tagConfig route source keeps its provider-ROOT refusal
237    /// (07-06): the pre-call bracket detection + RpcContext
238    /// translation both refuse `provider_root_unsupported` —
239    /// wiremock cannot execute the route's Python, so this source
240    /// pin is the route-side regression guard (alongside the
241    /// Rust-side denial mapping contract).
242    #[test]
243    fn tagconfig_route_source_refuses_provider_roots() {
244        let (_, source) = ROUTE_FILES
245            .iter()
246            .find(|(name, _)| {
247                *name == "com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py"
248            })
249            .expect("tagConfig doPost.py in the manifest");
250        assert!(
251            source.contains("provider_root_unsupported"),
252            "the tagConfig route must keep its provider-root refusal"
253        );
254        assert!(
255            source.contains("def is_provider_root("),
256            "the bracket-form detector must stay nested inside doPost (byte-0 rule)"
257        );
258        assert!(
259            source.contains("'No RpcContext' in traceback.format_exc()"),
260            "the bare-form RpcContext translation must stay"
261        );
262    }
263}