ignition-core 1.2.0

Core library for ign: config, profiles, gateway client, actions, error taxonomy
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! The response classifier — the ONE place that sees status,
//! content-type, and redirect Location BEFORE any `.json()` call
//! (02-RESEARCH §Error-Body Sniffing). Every pipeline helper in [`super`]
//! routes its responses through [`classify`]; no response body is ever
//! parsed before its status has been mapped into the LOCKED taxonomy.
//!
//! Dispatch order (prescriptive, from research):
//! 1. **2xx** → pass the response through for body parsing.
//! 2. **3xx** → `Location` containing `/welcome` means the gateway is
//!    uncommissioned (it 302s EVERYTHING at the wizard); any other 3xx
//!    (e.g. `/idp/…` on `/data/app/*`) is an auth-class redirect.
//! 3. **401/403** → `Auth` (exit 5) — the status-aware hints in
//!    `CoreError::hint()` carry the name:key / three-parts guidance.
//! 4. **503** → `GatewayRestarting` (exit 6) — the webserver answers 503
//!    while the gateway restarts (verified lifecycle; never a decode error).
//! 5. **404** → `NotFound` (exit 6) — missing resource or a pre-8.3
//!    gateway's `No route match` JSON.
//! 6. Anything else → `Internal`, enriched with the Jetty HTML page's
//!    own title/message when the body is HTML (see [`html_error_parts`]).
//!
//! **6b.** api-call path + unclassified 4xx → `GatewayClientError` (exit
//! 2, verbatim body): the `api_call` parameter scopes this arm to the raw
//! api-call pipeline (the `send_and_classify_for_api` entry, 09-01) — a
//! 4xx the CLI does not curate is the CALLER's usage problem, and the
//! body is theirs to read. The curated pipeline passes `api_call = false`,
//! so v1.0-era commands' exit-1 semantics are untouched (Pitfall 1: a
//! global catch-all would reclassify them). The earlier match arms
//! (401/403, 503, 404, the route-scoped 409/422 arms) run BEFORE this
//! fallback and keep their meanings on both paths.
//!
//! The Jetty sniffer is a deliberate substring scan, not an HTML crate:
//! the error pages are a fixed server template (research Don't-Hand-Roll).

use serde_json::Value;

use crate::error::CoreError;

/// Classify `resp` (from `url`) into `Ok(response)` on 2xx or the typed
/// [`CoreError`] every other observed gateway shape maps to. Consumes the
/// body ONLY on the unclassifiable fallback (to sniff the HTML detail);
/// classified variants keep their fixed Display strings.
///
/// `api_call` scopes the catch-all (09-01): only the raw api-call
/// pipeline sets it, so an UNCLASSIFIED 4xx there maps to
/// [`CoreError::GatewayClientError`] (exit 2, verbatim truncated body)
/// instead of `Internal`. Every curated call site passes `false` — the
/// arm is parameter-scoped, not global (Pitfall 1).
pub(crate) async fn classify(
    resp: reqwest::Response,
    url: &str,
    api_call: bool,
) -> Result<reqwest::Response, CoreError> {
    use reqwest::StatusCode as S;

    let status = resp.status();
    if status.is_success() {
        return Ok(resp);
    }

    // Redirects: an uncommissioned gateway 302s everything to /welcome
    // (reqwest is configured with Policy::none() so we SEE the 3xx);
    // other redirect targets (e.g. /idp on /data/app/*) are auth-class.
    if status.is_redirection() {
        let location = resp
            .headers()
            .get(reqwest::header::LOCATION)
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default();
        if location.contains("/welcome") {
            return Err(CoreError::GatewayNotCommissioned {
                endpoint: Some(url.to_string()),
            });
        }
        return Err(CoreError::Auth {
            status: status.as_u16(),
            endpoint: Some(url.to_string()),
        });
    }

    match status {
        // The EAM controller state gate (07-02, the trial_not_expired
        // pattern's classify edition): a 403 on a /data/eam/ path
        // whose body carries the controller message is a STATE
        // refusal — the token is fine, the module's role is not
        // (live-proven 8.3.3: every /data/eam/api/v1/* endpoint
        // answers exactly this). Path-scoped + content-scoped so a
        // generic under-permitted 403 elsewhere (or on an EAM path
        // with a different message) keeps the honest Auth mapping.
        S::FORBIDDEN if is_eam_url(url) => {
            let body = resp.text().await.unwrap_or_default();
            if body.contains("configured as a controller") {
                return Err(CoreError::EamNotController {
                    endpoint: Some(url.to_string()),
                });
            }
            Err(CoreError::Auth {
                status: status.as_u16(),
                endpoint: Some(url.to_string()),
            })
        }
        S::UNAUTHORIZED | S::FORBIDDEN => Err(CoreError::Auth {
            status: status.as_u16(),
            endpoint: Some(url.to_string()),
        }),
        S::SERVICE_UNAVAILABLE => Err(CoreError::GatewayRestarting {
            endpoint: Some(url.to_string()),
        }),
        S::NOT_FOUND => Err(CoreError::NotFound {
            endpoint: Some(url.to_string()),
        }),
        // 409 on the DESIGNER-PRUNE route only — route-scoped via the
        // URL (the singular prune path `/data/api/v1/designer/{id}` is
        // distinct from the plural `/designers` list; see
        // [`is_designer_prune_url`]). A LIVE Designer session answers
        // the prune DELETE with 409 + empty body (wire-verified 8.3.3,
        // 06-UAT test 6): prune removes STALE entries only, a
        // target-state refusal — not an internal error. Every other
        // route's 409 keeps the Internal fallback below.
        //
        // The Perspective terminate 404 ("No valid sessions found to
        // close" — the id-vs-scope mismatch of a Designer-embedded
        // session) is deliberately NOT distinguished: classify never
        // reads bodies outside the Internal fallback, and that 404
        // body's shape is unverified on the wire (only the openapi
        // DECLARES the message) — the honest generic `not_found`
        // stands until a capture proves a distinguishable marker.
        S::CONFLICT if is_designer_prune_url(url) => Err(CoreError::SessionNotPrunable {
            id: designer_prune_id(url),
            endpoint: Some(url.to_string()),
        }),
        // 409 on the EAM FORCE route only (07-06 gap 4, the
        // session_not_prunable precedent's force-route edition): a
        // leftover '(forced)' run occupies the task's slot — the
        // gateway answers 409 with its own Jetty error page ("Task
        // 'X (forced)' already exists! It must be completed or
        // deleted before another task of this type can be force
        // executed."; live-captured 8.3.3, 07-UAT test 7). The
        // page's MESSAGE rides the refusal verbatim; a 409 without
        // the page keeps the '(forced)' fallback detail. Every other
        // route's 409 keeps the Internal fallback below.
        S::CONFLICT if is_eam_force_url(url) => {
            let body = resp.text().await.unwrap_or_default();
            let detail = html_error_parts(&body)
                .map(|(_, message)| message)
                .filter(|message| !message.is_empty())
                .unwrap_or_else(|| {
                    "the previous '(forced)' run must be completed or deleted first".to_string()
                });
            Err(CoreError::EamTaskInFlight {
                task: eam_force_task_name(url),
                detail,
                endpoint: Some(url.to_string()),
            })
        }
        // A 422 on a config-RESOURCE path (07-05 gap 3): the gateway
        // rejected a client-composed resource BODY — validation, not
        // an internal error. Path-scoped (the EAM create path
        // `/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks`
        // live-answers 422 `{"messages":["Settings cannot be
        // null"],"fieldMessages":[]}` on 8.3.3) so runtime endpoints
        // keep the Internal fallback. The `messages` array joins into
        // the reason; a non-JSON body rides its raw text; an empty
        // body stays a bare 422 note (the EamNotController arm's
        // body-reading precedent).
        S::UNPROCESSABLE_ENTITY if is_config_resource_url(url) => {
            let body = resp.text().await.unwrap_or_default();
            let joined = serde_json::from_str::<Value>(&body)
                .ok()
                .and_then(|parsed| {
                    parsed["messages"].as_array().map(|messages| {
                        messages
                            .iter()
                            .filter_map(Value::as_str)
                            .collect::<Vec<_>>()
                            .join("; ")
                    })
                })
                .filter(|joined| !joined.is_empty());
            let reason = match joined {
                Some(joined) => {
                    format!("gateway rejected the resource body (HTTP 422 from {url}): {joined}")
                }
                None if !body.trim().is_empty() => {
                    format!("gateway rejected the resource body (HTTP 422 from {url}): {body}")
                }
                None => format!("gateway rejected the resource body (HTTP 422 from {url})"),
            };
            Err(CoreError::InvalidInput { reason })
        }
        _ => {
            // The api-call catch-all (09-01): an UNCLASSIFIED 4xx on the
            // api-call path is the caller's request failing, not a CLI
            // bug — the gateway's verbatim body is theirs to read
            // (capped + marked at construction via truncate_api_body).
            // Parameter-scoped so the curated pipeline's Internal/exit-1
            // semantics are byte-for-byte unchanged (Pitfall 1); the
            // earlier arms (401/403/404/503 + the route-scoped 409/422
            // arms) already ran and keep their meanings on the api path.
            if api_call && status.is_client_error() {
                let body = resp.text().await.unwrap_or_default();
                return Err(CoreError::GatewayClientError {
                    status: status.as_u16(),
                    endpoint: url.to_string(),
                    body: crate::error::truncate_api_body(&body),
                });
            }
            // Unclassifiable: if the body is the Jetty HTML error page,
            // surface its own title/message instead of a bare status.
            let is_html = resp
                .headers()
                .get(reqwest::header::CONTENT_TYPE)
                .and_then(|value| value.to_str().ok())
                .is_some_and(|ct| ct.to_ascii_lowercase().contains("text/html"));
            let detail = if is_html {
                let body = resp.text().await.unwrap_or_default();
                html_error_parts(&body).map(|(code, message)| {
                    format!(" (gateway error page: Error {code}: {message})")
                })
            } else {
                None
            };
            Err(CoreError::Internal(format!(
                "unexpected HTTP {status} from {url}{}",
                detail.unwrap_or_default()
            )))
        }
    }
}

/// Is `url` on the EAM runtime surface (`/data/eam/`)? The
/// controller-403 classification is scoped to this prefix — the
/// module-scoped seam (the designer-prune route scoping precedent).
fn is_eam_url(url: &str) -> bool {
    url.contains("/data/eam/")
}

/// Is `url` on the config-RESOURCE surface
/// (`/data/api/v1/resources/`)? The 422 body-rejection arm is
/// scoped to this prefix — resource create/PUT bodies are
/// client-composed, so a 422 is OUR payload failing the server's
/// validation (`invalid_input`); everything else keeps the Internal
/// fallback.
fn is_config_resource_url(url: &str) -> bool {
    url.contains("/data/api/v1/resources/")
}

/// Is `url` on the EAM FORCE route
/// (`…/data/eam/api/v1/eam-tasks/force/…`)? The in-flight 409 arm
/// is scoped to this prefix — the history route and the
/// config-resource definition paths keep the Internal fallback.
fn is_eam_force_url(url: &str) -> bool {
    url.contains("/data/eam/api/v1/eam-tasks/force/")
}

/// The forced task's name — the LAST path segment after the
/// force-route prefix (the URL is `/eam-tasks/force/{owner}/{name}`;
/// query-safe like [`designer_prune_id`]).
fn eam_force_task_name(url: &str) -> String {
    url.split_once("/data/eam/api/v1/eam-tasks/force/")
        .map(|(_, tail)| {
            tail.split('?')
                .next()
                .unwrap_or_default()
                .rsplit('/')
                .next()
                .unwrap_or_default()
                .to_string()
        })
        .unwrap_or_default()
}

/// Is `url` the SINGULAR designer-prune route
/// (`…/data/api/v1/designer/{id}`)? The trailing `/designer/` segment
/// cannot match the plural `/data/api/v1/designers` list — the `s`
/// closes the path segment before any slash appears.
fn is_designer_prune_url(url: &str) -> bool {
    url.contains("/data/api/v1/designer/")
}

/// The pruned session id — the path segment after the prune-route
/// prefix (query-safe: anything from `?` on is not part of the id; the
/// prune DELETE carries no query params today, this is just honest
/// plumbing).
fn designer_prune_id(url: &str) -> String {
    url.split_once("/data/api/v1/designer/")
        .map(|(_, tail)| {
            tail.split('?')
                .next()
                .unwrap_or_default()
                .trim_end_matches('/')
                .to_string()
        })
        .unwrap_or_default()
}

/// Extract `(status, message)` from the fixed Jetty error-page template
/// via substring scan — `<title>Error NNN</title>` and
/// `<th>MESSAGE:</th><td>…</td>`. Returns `None` when either anchor is
/// absent (never guess: the fallback keeps its bare status text).
fn html_error_parts(body: &str) -> Option<(u16, String)> {
    const TITLE_ANCHOR: &str = "<title>Error ";
    const TITLE_END: &str = "</title>";
    const MESSAGE_ANCHOR: &str = "<th>MESSAGE:</th><td>";
    const MESSAGE_END: &str = "</td>";

    let title_start = body.find(TITLE_ANCHOR)? + TITLE_ANCHOR.len();
    let title_end = title_start + body[title_start..].find(TITLE_END)?;
    let code: u16 = body[title_start..title_end].trim().parse().ok()?;

    let message = body
        .find(MESSAGE_ANCHOR)
        .map(|start| {
            let start = start + MESSAGE_ANCHOR.len();
            let end = body[start..]
                .find(MESSAGE_END)
                .map_or(body.len(), |relative| start + relative);
            body[start..end].to_string()
        })
        .unwrap_or_default();

    Some((code, message))
}

#[cfg(test)]
mod tests {
    use super::{
        designer_prune_id, eam_force_task_name, html_error_parts, is_config_resource_url,
        is_designer_prune_url, is_eam_force_url, is_eam_url,
    };

    /// The 422 arm's path scoping (07-05): the EAM create path (and
    /// every config-resource path) matches; the EAM runtime paths
    /// and gateway-info do NOT (they keep the Internal fallback).
    #[test]
    fn config_resource_url_detection_scopes_the_422_arm() {
        assert!(is_config_resource_url(
            "http://gw:8088/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks"
        ));
        assert!(is_config_resource_url(
            "http://gw:8088/data/api/v1/resources/list/com.inductiveautomation.eam/eam-tasks"
        ));
        assert!(!is_config_resource_url(
            "http://gw:8088/data/eam/api/v1/eam-tasks/history"
        ));
        assert!(!is_config_resource_url(
            "http://gw:8088/data/api/v1/gateway-info"
        ));
    }

    /// The EAM controller-403 scoping (07-02): the runtime prefix
    /// matches; the config-resource definition paths do NOT (they
    /// answer normally on stock gateways — definitions are plain
    /// config resources).
    #[test]
    fn eam_url_detection_is_the_runtime_prefix() {
        assert!(is_eam_url(
            "http://gw:8088/data/eam/api/v1/eam-tasks/history"
        ));
        assert!(is_eam_url(
            "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/t1"
        ));
        assert!(!is_eam_url(
            "http://gw:8088/data/api/v1/resources/list/com.inductiveautomation.eam/eam-tasks"
        ));
        assert!(!is_eam_url("http://gw:8088/data/api/v1/gateway-info"));
    }

    /// The EXACT Jetty error page captured from the live 8.3.6 gateway
    /// (02-RESEARCH §Code Examples) — the golden fixture for the sniffer.
    const CAPTURED_401_HTML: &str = r#"<html><head><meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/><title>Error 401</title></head><body><h2>HTTP ERROR 401 Unauthorized</h2><table><tr><th>URI:</th><td>/data/api/v1/gateway-info</td></tr><tr><th>STATUS:</th><td>401</td></tr><tr><th>MESSAGE:</th><td>Unauthorized</td></tr></table></body></html>"#;

    /// The raw 401 page re-captured verbatim from the still-running
    /// research rig (curl, 2026-08-21): the same fixed template WITH its
    /// inter-row newlines and blank line before `</body>` — pins that the
    /// substring anchors tolerate the wire formatting, not just the
    /// compacted doc form.
    const CAPTURED_401_HTML_RAW: &str = "<html>\n<head>\n<meta http-equiv=\"Content-Type\" content=\"text/html;charset=ISO-8859-1\"/>\n<title>Error 401</title>\n</head>\n<body><h2>HTTP ERROR 401 Unauthorized</h2>\n<table>\n<tr><th>URI:</th><td>/data/api/v1/gateway-info</td></tr>\n<tr><th>STATUS:</th><td>401</td></tr>\n<tr><th>MESSAGE:</th><td>Unauthorized</td></tr>\n</table>\n\n</body>\n</html>\n";

    /// Same fixed template with a 500 title/message — proves the scan is
    /// template-driven, not a 401 hardcode.
    const CAPTURED_500_HTML: &str = r#"<html><head><meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/><title>Error 500</title></head><body><h2>HTTP ERROR 500 Server Error</h2><table><tr><th>URI:</th><td>/data/api/v1/gateway-info</td></tr><tr><th>STATUS:</th><td>500</td></tr><tr><th>MESSAGE:</th><td>Server Error</td></tr></table></body></html>"#;

    #[test]
    fn sniffs_the_captured_jetty_401_page() {
        assert_eq!(
            html_error_parts(CAPTURED_401_HTML),
            Some((401, "Unauthorized".to_string()))
        );
    }

    #[test]
    fn sniffs_the_raw_wire_capture_with_newlines() {
        assert_eq!(
            html_error_parts(CAPTURED_401_HTML_RAW),
            Some((401, "Unauthorized".to_string()))
        );
    }

    #[test]
    fn sniffs_the_500_template_too() {
        assert_eq!(
            html_error_parts(CAPTURED_500_HTML),
            Some((500, "Server Error".to_string()))
        );
    }

    #[test]
    fn returns_none_for_non_template_bodies() {
        assert_eq!(html_error_parts("<html><body>welcome</body></html>"), None);
        assert_eq!(html_error_parts(""), None);
        assert_eq!(html_error_parts("{\"message\":\"json\"}"), None);
    }

    /// Route-scoping of the 409 arm (06-07): the SINGULAR prune path
    /// matches — the plural designers LIST path does not (its `s`
    /// closes the segment), and neither does any other route.
    #[test]
    fn designer_prune_route_detection_is_exact() {
        assert!(is_designer_prune_url(
            "http://gw:8088/data/api/v1/designer/d-live-1"
        ));
        assert!(
            !is_designer_prune_url("http://gw:8088/data/api/v1/designers"),
            "the plural list route must NOT match"
        );
        assert!(
            !is_designer_prune_url("http://gw:8088/data/api/v1/designers?limit=1"),
            "the list route with query params must NOT match"
        );
        assert!(!is_designer_prune_url(
            "http://gw:8088/data/perspective/api/v1/sessions"
        ));
    }

    /// The pruned id rides the trailing path segment (query-safe).
    #[test]
    fn designer_prune_id_extracts_the_trailing_segment() {
        assert_eq!(
            designer_prune_id("http://gw:8088/data/api/v1/designer/d-live-1"),
            "d-live-1"
        );
        assert_eq!(
            designer_prune_id("http://gw:8088/data/api/v1/designer/10443A91?x=1"),
            "10443A91"
        );
    }

    /// The force-409 arm's path scoping (07-06 gap 4): the FORCE
    /// prefix matches; the history route and the config-resource
    /// definition path do NOT (they keep the Internal fallback).
    #[test]
    fn eam_force_url_detection_scopes_the_409_arm() {
        assert!(is_eam_force_url(
            "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
        ));
        assert!(!is_eam_force_url(
            "http://gw:8088/data/eam/api/v1/eam-tasks/history"
        ));
        assert!(!is_eam_force_url(
            "http://gw:8088/data/api/v1/resources/list/com.inductiveautomation.eam/eam-tasks"
        ));
    }

    /// The forced task's name is the LAST force-route segment after
    /// the owner (query-safe like `designer_prune_id`).
    #[test]
    fn eam_force_task_name_extracts_the_trailing_segment() {
        assert_eq!(
            eam_force_task_name(
                "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/cli-research-backup"
            ),
            "cli-research-backup"
        );
        assert_eq!(
            eam_force_task_name(
                "http://gw:8088/data/eam/api/v1/eam-tasks/force/eam/nightly-backup?x=1"
            ),
            "nightly-backup"
        );
    }
}