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
23/// Version of the embedded route bundle — the `version` handshake action
24/// in every route answers with this value (as `routeVersion`).
25pub const ROUTE_BUNDLE_VERSION: &str = "1.3.0";
26
27/// Minimum CLI version the deployed routes require (handshake `minCli`).
28pub const MIN_CLI: &str = "1.0";
29
30/// The always-on deploy set: `(zip_member_path, contents)` pairs.
31///
32/// Member paths are forward-slash zip paths in the Designer-native layout
33/// the gateway import expects (`project.json` at the root, route folders
34/// under `com.inductiveautomation.webdev/resources/cli/<route>/`). The 13
35/// members are `project.json` plus four route folders — tags, tagConfig,
36/// alarms, tagHistory — times three files each (`resource.json`,
37/// `config.json`, `doPost.py`).
38pub const ROUTE_FILES: &[(&str, &str)] = &[
39    // Deploy project manifest.
40    (
41        "project.json",
42        include_str!("../../webdev/routes/project.json"),
43    ),
44    // tags — live tag values (version/browse/read/write).
45    (
46        "com.inductiveautomation.webdev/resources/cli/tags/resource.json",
47        include_str!(
48            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/resource.json"
49        ),
50    ),
51    (
52        "com.inductiveautomation.webdev/resources/cli/tags/config.json",
53        include_str!(
54            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/config.json"
55        ),
56    ),
57    (
58        "com.inductiveautomation.webdev/resources/cli/tags/doPost.py",
59        include_str!(
60            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tags/doPost.py"
61        ),
62    ),
63    // tagConfig — configuration CRUD, UDTs, bulk export.
64    (
65        "com.inductiveautomation.webdev/resources/cli/tagConfig/resource.json",
66        include_str!(
67            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/resource.json"
68        ),
69    ),
70    (
71        "com.inductiveautomation.webdev/resources/cli/tagConfig/config.json",
72        include_str!(
73            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/config.json"
74        ),
75    ),
76    (
77        "com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py",
78        include_str!(
79            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py"
80        ),
81    ),
82    // alarms — active status, journal history, acknowledge.
83    (
84        "com.inductiveautomation.webdev/resources/cli/alarms/resource.json",
85        include_str!(
86            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/resource.json"
87        ),
88    ),
89    (
90        "com.inductiveautomation.webdev/resources/cli/alarms/config.json",
91        include_str!(
92            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/config.json"
93        ),
94    ),
95    (
96        "com.inductiveautomation.webdev/resources/cli/alarms/doPost.py",
97        include_str!(
98            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/alarms/doPost.py"
99        ),
100    ),
101    // tagHistory — historical tag value queries.
102    (
103        "com.inductiveautomation.webdev/resources/cli/tagHistory/resource.json",
104        include_str!(
105            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/resource.json"
106        ),
107    ),
108    (
109        "com.inductiveautomation.webdev/resources/cli/tagHistory/config.json",
110        include_str!(
111            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/config.json"
112        ),
113    ),
114    (
115        "com.inductiveautomation.webdev/resources/cli/tagHistory/doPost.py",
116        include_str!(
117            "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/tagHistory/doPost.py"
118        ),
119    ),
120];
121
122/// The scriptExec route TEMPLATE — secret-gated arbitrary script execution.
123///
124/// Kept separate from [`ROUTE_FILES`] because deploy (05-03) must
125/// substitute the `__IGN_CLI_SECRET__` marker with the deploy-time hex
126/// secret BEFORE packing this member: shipping the template unsubstituted
127/// would arm the gate with a publicly-known placeholder value. The route
128/// itself fail-closes on exactly that state, and this separation is the
129/// structural guarantee it never happens.
130pub const SCRIPT_EXEC_TEMPLATE: &str = include_str!(
131    "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/doPost.py"
132);
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    // The repo-level VERSION file — the third copy of the handshake
139    // version, pinned here so all three must move together.
140    const VERSION_FILE: &str = include_str!("../../webdev/routes/VERSION");
141
142    /// (1) Every always-on doPost.py carries the handshake constants, and
143    /// they match the Rust embed — route sources and binary must never
144    /// drift. (String-containment: Jython isn't parseable here.)
145    #[test]
146    fn route_sources_carry_the_embedded_handshake_constants() {
147        let route_version = format!("ROUTE_VERSION = '{}'", ROUTE_BUNDLE_VERSION);
148        let min_cli = format!("MIN_CLI = '{}'", MIN_CLI);
149        let mut do_post_count = 0;
150        for (name, contents) in ROUTE_FILES {
151            if name.ends_with("doPost.py") {
152                do_post_count += 1;
153                assert!(
154                    contents.contains(&route_version),
155                    "{name}: missing {route_version}"
156                );
157                assert!(contents.contains(&min_cli), "{name}: missing {min_cli}");
158            }
159        }
160        assert_eq!(do_post_count, 4, "expected the four always-on dispatchers");
161        assert_eq!(
162            VERSION_FILE.trim(),
163            ROUTE_BUNDLE_VERSION,
164            "webdev/routes/VERSION drifted from ROUTE_BUNDLE_VERSION"
165        );
166    }
167
168    /// (2) The always-on bundle must carry NO secret placeholder — the
169    /// marker exists only in the scriptExec template.
170    #[test]
171    fn always_on_bundle_carries_no_secret_placeholder() {
172        for (name, contents) in ROUTE_FILES {
173            assert!(
174                !contents.contains("__IGN_CLI_SECRET__"),
175                "{name}: the secret placeholder must not ship in the always-on bundle"
176            );
177        }
178    }
179
180    /// (3) The scriptExec template is substitutable exactly once and
181    /// fail-closed by default.
182    #[test]
183    fn script_exec_template_is_substitutable_and_fail_closed() {
184        assert_eq!(
185            SCRIPT_EXEC_TEMPLATE.matches("__IGN_CLI_SECRET__").count(),
186            1,
187            "the deploy substitution needs exactly one marker occurrence"
188        );
189        assert!(
190            SCRIPT_EXEC_TEMPLATE.contains("SECRET = None"),
191            "the template must keep its fail-closed SECRET default"
192        );
193    }
194
195    /// (4) Zip member names use forward slashes only — a backslash would
196    /// produce a broken member on every non-Windows zip reader and a
197    /// differently-named one on Windows.
198    #[test]
199    fn member_names_use_forward_slashes_only() {
200        for (name, _) in ROUTE_FILES {
201            assert!(!name.contains('\\'), "backslash in member name: {name}");
202        }
203    }
204
205    /// (5) The manifest is exactly the 13-member always-on set: one
206    /// project.json plus four route folders × three files, all under the
207    /// Designer-native route root.
208    #[test]
209    fn manifest_lists_exactly_thirteen_members() {
210        assert_eq!(ROUTE_FILES.len(), 13);
211        assert_eq!(
212            ROUTE_FILES
213                .iter()
214                .filter(|(name, _)| *name == "project.json")
215                .count(),
216            1
217        );
218        assert_eq!(
219            ROUTE_FILES
220                .iter()
221                .filter(|(name, _)| name.ends_with("doPost.py"))
222                .count(),
223            4
224        );
225        for (name, _) in ROUTE_FILES {
226            assert!(
227                *name == "project.json"
228                    || name.starts_with("com.inductiveautomation.webdev/resources/cli/"),
229                "member outside the Designer-native layout: {name}"
230            );
231        }
232    }
233
234    /// (6) The tagConfig route source keeps its provider-ROOT refusal
235    /// (07-06): the pre-call bracket detection + RpcContext
236    /// translation both refuse `provider_root_unsupported` —
237    /// wiremock cannot execute the route's Python, so this source
238    /// pin is the route-side regression guard (alongside the
239    /// Rust-side denial mapping contract).
240    #[test]
241    fn tagconfig_route_source_refuses_provider_roots() {
242        let (_, source) = ROUTE_FILES
243            .iter()
244            .find(|(name, _)| {
245                *name == "com.inductiveautomation.webdev/resources/cli/tagConfig/doPost.py"
246            })
247            .expect("tagConfig doPost.py in the manifest");
248        assert!(
249            source.contains("provider_root_unsupported"),
250            "the tagConfig route must keep its provider-root refusal"
251        );
252        assert!(
253            source.contains("def is_provider_root("),
254            "the bracket-form detector must stay nested inside doPost (byte-0 rule)"
255        );
256        assert!(
257            source.contains("'No RpcContext' in traceback.format_exc()"),
258            "the bare-form RpcContext translation must stay"
259        );
260    }
261}