Skip to main content

ignition_core/client/
webdev.rs

1//! The WebDev client seam (05-03) — the CLI's own gateway-side
2//! surface at `/system/webdev/{project}/cli/{route}` (NOT
3//! `/data/webdev/*` — that prefix does not exist; 05-RESEARCH wire
4//! protocol, live-proven on 8.3.3).
5//!
6//! This module owns the seam's PURE pieces so the trait impl in
7//! [`super`] stays thin delegation (the per-capability-file
8//! convention): the ONE path builder ([`route_url`]), the
9//! presence/version discrimination enum ([`RouteProbe`]), the
10//! shared 200-body envelope parser ([`parse_route_body`]) and its
11//! taxonomy mapping ([`denial_to_error`]), and the deploy zip
12//! builder ([`build_deploy_zip`]).
13//!
14//! THE wire rules pinned here (05-RESEARCH, all live-proven):
15//! - **405 = absent, NOT 404** — missing routes AND missing projects
16//!   both answer 405 (Pitfall 1; doctor's Phase-2 404 assumption was
17//!   wrong and 05-03 re-pins it).
18//! - **402 = module unlicensed** — a trial-expired gateway's WebDev
19//!   servlet answers 402 with an HTML page (cross-verified 8.3.6).
20//! - **Denials ride HTTP 200** — WebDev IGNORES a `status` key in
21//!   route returns; every refusal is detectable only from the body
22//!   envelope `{ok, data|error}`. The status code alone is NEVER a
23//!   success verdict (Pitfall 2).
24//!
25//! [`build_deploy_zip`] packs the embedded 05-01 bundle
26//! ([`crate::webdev`]) into the project-zip the deploy action
27//! uploads through the 03-02 import machinery — scriptExec ONLY with
28//! a SUBSTITUTED secret (fail closed: the template's placeholder
29//! must never ship, the 05-01 structural guarantee enforced here at
30//! the type level).
31
32use std::io::Write;
33
34use serde_json::Value;
35
36use crate::error::CoreError;
37use crate::webdev as bundle;
38
39/// The deploy project the CLI owns wholesale — born from the first
40/// deploy zip, overwrite-replaced by every later deploy (05-RESEARCH
41/// deploy guidance; `--project` overrides it deliberately).
42pub const DEFAULT_PROJECT: &str = "ign-cli";
43
44/// The scriptExec route folder's zip root (the template's static
45/// siblings — 05-01 embedded only the doPost.py TEMPLATE in
46/// `crate::webdev`, so the two gate files embed HERE, at the seam
47/// that packs them).
48const SCRIPT_EXEC_ROUTE_ROOT: &str = "com.inductiveautomation.webdev/resources/cli/scriptExec";
49const SCRIPT_EXEC_RESOURCE_JSON: &str = include_str!(
50    "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/resource.json"
51);
52const SCRIPT_EXEC_CONFIG_JSON: &str = include_str!(
53    "../../webdev/routes/com.inductiveautomation.webdev/resources/cli/scriptExec/config.json"
54);
55
56/// The always-on route folders, in manifest order — DERIVED from
57/// [`bundle::ROUTE_FILES`] so the deploy set, the status sweep, and
58/// the manifest itself can never drift apart.
59pub fn always_on_routes() -> Vec<String> {
60    let mut routes = Vec::new();
61    for (name, _) in bundle::ROUTE_FILES {
62        let Some(rest) = name.strip_prefix("com.inductiveautomation.webdev/resources/cli/") else {
63            continue;
64        };
65        if let Some((route, file)) = rest.rsplit_once('/')
66            && file == "doPost.py"
67            && !routes.iter().any(|known: &String| known == route)
68        {
69            routes.push(route.to_string());
70        }
71    }
72    routes
73}
74
75/// Path builder: `/system/webdev/{project}/cli/{route}` — the `cli/`
76/// folder segment is PART of the route folder path (the wire
77/// protocol's URL shape; the `cli` folder groups the CLI's routes
78/// inside the deploy project).
79pub(crate) fn route_url(project: &str, route: &str) -> String {
80    format!("/system/webdev/{project}/cli/{route}")
81}
82
83/// The presence/version discrimination — the probe enum. The status
84/// code IS the answer (deliberately NOT run through classify, the
85/// [`super::GatewayApi::webdev_route_status`] precedent); only
86/// transport failures are errors.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub enum RouteProbe {
89    /// 200 + ok body from the version action: deployed and answering
90    /// its handshake `routeVersion`.
91    Present {
92        /// The route's `routeVersion` handshake answer.
93        route_version: String,
94    },
95    /// 405 — the live-proven 8.3 absent marker (missing route or
96    /// missing project; NOT 404).
97    Absent,
98    /// 402 — the WebDev module is installed but unlicensed
99    /// (trial-expired gateway).
100    Unlicensed,
101    /// 401/403 — something answers at the path but rejects the
102    /// credential: present but auth-gated (research Open Question 4's
103    /// resolution: report, never conflate with absent).
104    AuthGated,
105    /// 200 body denial (`{ok:false, error{code,message}}`) — present
106    /// and refusing: the scriptExec gate's `secret_required` /
107    /// `secret_mismatch`, or any other stable route-contract code.
108    Denied {
109        /// The route's machine error code (05-01 contract).
110        code: String,
111        /// The route's human message.
112        message: String,
113        /// The route's Python traceback, when the denial carries one
114        /// (the envelope's optional `error.traceback` — surfaced so
115        /// route-side exceptions are not a black box, 05-08).
116        traceback: Option<String>,
117    },
118}
119
120/// The 200-body verdict shared by [`super::GatewayApi::webdev_route_call`]
121/// and [`super::GatewayApi::webdev_route_probe`].
122#[derive(Debug)]
123pub(crate) enum RouteBody {
124    /// `ok:true` — `data` (Null when the route sent none).
125    Ok(Value),
126    /// `ok:false` — the route's structured refusal (traceback when
127    /// the envelope carried one).
128    Denied {
129        code: String,
130        message: String,
131        traceback: Option<String>,
132    },
133}
134
135/// Parse a 200 body as the route envelope. A body that is not the
136/// `{ok, data|error}` shape is an internal-class honesty error — the
137/// CLI's own routes ALWAYS answer the envelope, so anything else
138/// means the path is not ours (a foreign route or an HTML error page
139/// that smuggled past the status line). Missing `error` fields
140/// degrade to the route contract's generic `route_error` code rather
141/// than guessing.
142pub(crate) fn parse_route_body(body: &str) -> Result<RouteBody, CoreError> {
143    let value: Value = serde_json::from_str(body).map_err(|err| {
144        CoreError::Internal(format!(
145            "webdev route answered a body that is not the {{ok, data|error}} envelope: {err}"
146        ))
147    })?;
148    if value.get("ok").and_then(Value::as_bool) == Some(true) {
149        Ok(RouteBody::Ok(
150            value.get("data").cloned().unwrap_or(Value::Null),
151        ))
152    } else {
153        let code = value
154            .pointer("/error/code")
155            .and_then(Value::as_str)
156            .unwrap_or("route_error")
157            .to_string();
158        let message = value
159            .pointer("/error/message")
160            .and_then(Value::as_str)
161            .unwrap_or("(the route sent no message)")
162            .to_string();
163        let traceback = value
164            .pointer("/error/traceback")
165            .and_then(Value::as_str)
166            .map(str::to_string);
167        Ok(RouteBody::Denied {
168            code,
169            message,
170            traceback,
171        })
172    }
173}
174
175/// Map a body denial onto the taxonomy: the route contract's
176/// `not_found` code reuses the existing [`CoreError::NotFound`] slug
177/// (it means exactly that — the named thing is absent); the alarms
178/// route's `no_alarm_journal` maps to the actionable
179/// [`CoreError::AlarmJournalMissing`] (default rigs ALWAYS deny
180/// history there — the missing journal chain is target state, not a
181/// route bug, 05-06); every other code — known-but-unmapped like
182/// `secret_required`, or unknown from a future route — rides
183/// [`CoreError::WebdevRouteError`] with code + message verbatim, the
184/// stable contract agents branch on. A denial that carried a
185/// traceback gets it appended to the message
186/// (`"\nroute traceback: {tb}"`) — the route-side exception is
187/// visible instead of a black box (05-08); without one the message
188/// is byte-identical to the pre-traceback era.
189pub(crate) fn denial_to_error(
190    code: &str,
191    message: &str,
192    traceback: Option<&str>,
193    endpoint: String,
194) -> CoreError {
195    match code {
196        "not_found" => CoreError::NotFound {
197            endpoint: Some(endpoint),
198        },
199        "no_alarm_journal" => CoreError::AlarmJournalMissing {
200            endpoint: Some(endpoint),
201        },
202        // The tagConfig route's provider-root refusal (07-06): the
203        // route detects the bracket form pre-call and translates the
204        // bare form's 'No RpcContext' throw — the honest
205        // platform-limitation slug over a generic route error (the
206        // no_alarm_journal seam precedent).
207        "provider_root_unsupported" => CoreError::ProviderRootUnsupported {
208            endpoint: Some(endpoint),
209        },
210        _ => {
211            let mut full = message.to_string();
212            if let Some(traceback) = traceback {
213                full.push_str("\nroute traceback: ");
214                full.push_str(traceback);
215            }
216            CoreError::WebdevRouteError {
217                code: code.to_string(),
218                message: full,
219                endpoint: Some(endpoint),
220            }
221        }
222    }
223}
224
225/// Pack the deploy zip: the embedded always-on bundle VERBATIM (the
226/// project title substituted into `project.json`'s `title` ONLY when
227/// `project_title` differs from [`DEFAULT_PROJECT`] — the manifest
228/// already says `ign-cli`), plus — when `with_script_exec` —
229/// scriptExec's three members with the secret SUBSTITUTED into the
230/// template's `__IGN_CLI_SECRET__` marker (exactly-once replace; the
231/// 05-01 contract test pins the marker count).
232///
233/// FAIL CLOSED: `with_script_exec` + `None` secret is an internal
234/// bug guard (the deploy action generates the secret BEFORE packing;
235/// shipping the unsubstituted template would arm the gate with the
236/// publicly-known placeholder). `Some` WITHOUT `with_script_exec` is
237/// tolerated and ignored — a stored profile secret never forces a
238/// scriptExec deploy.
239///
240/// Members ride fixed `SimpleFileOptions` + deflate (the 05-02
241/// deterministic-zip convention) so identical inputs pack
242/// identically.
243pub fn build_deploy_zip(
244    project_title: &str,
245    with_script_exec: bool,
246    secret: Option<&str>,
247) -> Result<Vec<u8>, CoreError> {
248    let script_exec_py = match (with_script_exec, secret) {
249        (false, _) => None,
250        (true, Some(secret)) => {
251            Some(bundle::SCRIPT_EXEC_TEMPLATE.replace("__IGN_CLI_SECRET__", secret))
252        }
253        (true, None) => {
254            return Err(CoreError::Internal(
255                "scriptExec deploy requires a substituted secret — the deploy \
256                 action generates the secret before packing (fail-closed guard)"
257                    .into(),
258            ));
259        }
260    };
261
262    let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
263    let options = zip::write::SimpleFileOptions::default()
264        .compression_method(zip::CompressionMethod::Deflated);
265
266    for (name, contents) in bundle::ROUTE_FILES {
267        let body = if *name == "project.json" && project_title != DEFAULT_PROJECT {
268            retitle_project_json(contents, project_title)?
269        } else {
270            (*contents).to_string()
271        };
272        writer.start_file(*name, options).map_err(zip_write_err)?;
273        writer.write_all(body.as_bytes()).map_err(|err| {
274            CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
275        })?;
276    }
277
278    if let Some(script_exec_py) = script_exec_py {
279        for (name, body) in [
280            (
281                format!("{SCRIPT_EXEC_ROUTE_ROOT}/resource.json"),
282                SCRIPT_EXEC_RESOURCE_JSON.to_string(),
283            ),
284            (
285                format!("{SCRIPT_EXEC_ROUTE_ROOT}/config.json"),
286                SCRIPT_EXEC_CONFIG_JSON.to_string(),
287            ),
288            (
289                format!("{SCRIPT_EXEC_ROUTE_ROOT}/doPost.py"),
290                script_exec_py,
291            ),
292        ] {
293            writer
294                .start_file(name.as_str(), options)
295                .map_err(zip_write_err)?;
296            writer.write_all(body.as_bytes()).map_err(|err| {
297                CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
298            })?;
299        }
300    }
301
302    writer
303        .finish()
304        .map_err(zip_write_err)
305        .map(|cursor| cursor.into_inner())
306}
307
308fn zip_write_err(err: zip::result::ZipError) -> CoreError {
309    CoreError::Internal(format!("cannot build the webdev deploy zip: {err}"))
310}
311
312/// Swap `project.json`'s `title` for a `--project` override — only
313/// `title` moves (name/description/enabled/parent ride verbatim; the
314/// import NAME is the URL's concern, not the manifest's).
315fn retitle_project_json(project_json: &str, title: &str) -> Result<String, CoreError> {
316    let mut value: Value = serde_json::from_str(project_json).map_err(|err| {
317        CoreError::Internal(format!("embedded project.json does not parse: {err}"))
318    })?;
319    value["title"] = Value::String(title.to_string());
320    serde_json::to_string(&value)
321        .map_err(|err| CoreError::Internal(format!("cannot re-serialize project.json: {err}")))
322}
323
324#[cfg(test)]
325mod tests {
326    use super::{
327        DEFAULT_PROJECT, RouteBody, always_on_routes, build_deploy_zip, denial_to_error,
328        parse_route_body,
329    };
330    use crate::error::CoreError;
331
332    /// The route list is DERIVED from the manifest — the four
333    /// always-on folders, manifest order, no drift possible.
334    #[test]
335    fn always_on_routes_derive_from_the_manifest() {
336        assert_eq!(
337            always_on_routes(),
338            vec![
339                "tags".to_string(),
340                "tagConfig".to_string(),
341                "alarms".to_string(),
342                "tagHistory".to_string(),
343            ]
344        );
345    }
346
347    /// Envelope parsing: ok:true yields data; ok:false yields the
348    /// code+message; a non-envelope body is internal-class; missing
349    /// error fields degrade to the generic contract code.
350    #[test]
351    fn parse_route_body_envelope_shapes() {
352        match parse_route_body(r#"{"ok":true,"data":{"routeVersion":"1.0.0"}}"#)
353            .expect("ok body parses")
354        {
355            RouteBody::Ok(data) => {
356                assert_eq!(data["routeVersion"], "1.0.0");
357            }
358            other => panic!("wrong verdict: {other:?}"),
359        }
360
361        match parse_route_body(
362            r#"{"ok":false,"error":{"code":"secret_mismatch","message":"nope"}}"#,
363        )
364        .expect("denial parses")
365        {
366            RouteBody::Denied {
367                code,
368                message,
369                traceback,
370            } => {
371                assert_eq!(code, "secret_mismatch");
372                assert_eq!(message, "nope");
373                assert!(traceback.is_none(), "no traceback on the wire");
374            }
375            other => panic!("wrong verdict: {other:?}"),
376        }
377
378        // A denial carrying the optional traceback keeps it (the
379        // black-box fix's parse half).
380        match parse_route_body(
381            r#"{"ok":false,"error":{"code":"route_error","message":"boom","traceback":"Traceback (most recent call last):\n  ValueError: nope"}}"#,
382        )
383        .expect("denial with traceback parses")
384        {
385            RouteBody::Denied { code, traceback, .. } => {
386                assert_eq!(code, "route_error");
387                assert_eq!(
388                    traceback.as_deref(),
389                    Some("Traceback (most recent call last):\n  ValueError: nope")
390                );
391            }
392            other => panic!("wrong verdict: {other:?}"),
393        }
394
395        // ok:true without data → Null (routes may answer bare oks).
396        match parse_route_body(r#"{"ok":true}"#).expect("bare ok parses") {
397            RouteBody::Ok(data) => assert!(data.is_null()),
398            other => panic!("wrong verdict: {other:?}"),
399        }
400
401        // ok:false without an error object → the generic code, never
402        // a guess.
403        match parse_route_body(r#"{"ok":false}"#).expect("bare denial parses") {
404            RouteBody::Denied { code, .. } => assert_eq!(code, "route_error"),
405            other => panic!("wrong verdict: {other:?}"),
406        }
407
408        let err = parse_route_body("<html>jetty</html>").expect_err("non-envelope fails");
409        assert!(matches!(err, CoreError::Internal(_)), "{err}");
410    }
411
412    /// The taxonomy mapping: `not_found` reuses the existing slug;
413    /// everything else (known secret codes included) rides
414    /// `webdev_route_error` verbatim; a traceback appends to the
415    /// message (`\nroute traceback: {tb}`) while its absence keeps
416    /// the message byte-identical.
417    #[test]
418    fn denial_mapping_reuses_not_found_and_rides_the_rest() {
419        let not_found = denial_to_error("not_found", "no such path", None, "/x".into());
420        assert_eq!(not_found.code(), "not_found");
421        assert_eq!(not_found.exit_code(), 6);
422
423        let secret = denial_to_error("secret_required", "missing header", None, "/x".into());
424        assert_eq!(secret.code(), "webdev_route_error");
425        assert_eq!(secret.exit_code(), 6);
426        assert!(secret.to_string().contains("secret_required"));
427        assert!(
428            secret.to_string().contains("missing header"),
429            "no traceback → the message rides VERBATIM (no suffix)"
430        );
431
432        // THE black-box fix: a denial with a traceback shows it —
433        // the "Invalid UUID string" class is diagnosable from CLI
434        // output alone.
435        let blown = denial_to_error(
436            "route_error",
437            "error processing action",
438            Some("java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
439            "/x".into(),
440        );
441        assert_eq!(blown.code(), "webdev_route_error");
442        let text = blown.to_string();
443        assert!(
444            text.contains("\nroute traceback: java.lang.IllegalArgumentException: Invalid UUID string: 3f2504e0"),
445            "the traceback rides the message: {text}"
446        );
447
448        // The alarms route's journal-missing denial maps to the
449        // actionable slug (default rigs ALWAYS hit it — the missing
450        // journal chain is target state, not a route bug).
451        let journal = denial_to_error(
452            "no_alarm_journal",
453            "No alarm journal profile specified",
454            None,
455            "/system/webdev/ign-cli/cli/alarms".into(),
456        );
457        assert_eq!(journal.code(), "alarm_journal_missing");
458        assert_eq!(journal.exit_code(), 6);
459        assert!(
460            journal.hint().unwrap().contains("journal profile"),
461            "hint names the chain: {journal}"
462        );
463
464        // The tagConfig route's provider-root denial (07-06): the
465        // dedicated slug + exit 6, the message names the subtree
466        // workaround (the fixed Display — the route's own message
467        // stays consistent with it).
468        let root = denial_to_error(
469            "provider_root_unsupported",
470            "provider-root tag paths are not supported on WebDev threads (no RpcContext) -- use a subtree path like [provider]folder",
471            None,
472            "/system/webdev/ign-cli/cli/tagConfig".into(),
473        );
474        assert_eq!(root.code(), "provider_root_unsupported");
475        assert_eq!(root.exit_code(), 6);
476        assert!(
477            root.to_string().contains("subtree like [provider]folder"),
478            "the fixed Display names the subtree workaround: {root}"
479        );
480    }
481
482    /// THE fail-closed guard: scriptExec packing demands a secret —
483    /// `None` + `with_script_exec` refuses BEFORE any zip is built.
484    #[test]
485    fn deploy_zip_fails_closed_without_a_script_exec_secret() {
486        let err = build_deploy_zip(DEFAULT_PROJECT, true, None).expect_err("must refuse");
487        assert!(matches!(err, CoreError::Internal(_)), "{err}");
488        assert_eq!(err.exit_code(), 1);
489    }
490}