Skip to main content

ignition_core/client/
apicall.rs

1//! The raw api-call capability (09-03, EXT-01) — `ign api call`'s
2//! client half: the request model, the gateway-verbatim data
3//! envelope, and the two usage-class guards the CLI runs
4//! PRE-resolution (and the action re-runs).
5//!
6//! THE verbatim decision (research OQ1, RawValue): the success body
7//! is captured as TEXT and embedded via
8//! [`serde_json::value::RawValue::from_string`] — one call that both
9//! preserves the gateway's bytes (key order, unknown fields, number
10//! shapes) AND validates JSON. "Gateway-verbatim" is pinned to mean:
11//! no field dropped, no value coerced, key order preserved. A 2xx
12//! body that is NOT JSON refuses [`CoreError::Internal`] with an
13//! explanatory message — binary endpoints ride the logs/backup
14//! download pipelines, never this one (README's documented contract
15//! exception).
16//!
17//! THE refusal rule (research Pattern 2): user-supplied auth-pattern
18//! headers (`authorization`, `x-ignition-api-token`, `cookie` —
19//! case-insensitive, trimmed: HTTP header names are case-insensitive
20//! and trimming beats whitespace-prefix smuggling) are REFUSED, never
21//! stripped or silently overridden. `apply_auth` is the ONE
22//! auth-header site (the redaction boundary, CORE-02); a
23//! caller-supplied auth header would either shadow the profile
24//! credential or double-send — both dishonest, so the guard refuses
25//! loudly BEFORE any I/O.
26//!
27//! THE path rules (research OQ4/Pitfall 4 — one mechanism each): a
28//! leading `/` is required (`url_for` joins onto the profile URL);
29//! an absolute/foreign-host URL is refused (the join would silently
30//! rebase the request off the profile's gateway); `?` in the path is
31//! refused in favor of the ONE query mechanism, repeatable
32//! `--query k=v`.
33
34use crate::error::CoreError;
35
36/// Headers the raw passthrough REFUSES — auth comes from the profile,
37/// full stop (EXT-01 success criterion 4). Compared case-insensitively
38/// against the TRIMMED header name (Pitfall 3).
39const REFUSED_AUTH_HEADERS: [&str; 3] = ["authorization", "x-ignition-api-token", "cookie"];
40
41/// The raw passthrough request: arbitrary method, caller path, raw
42/// body text, extra headers, and query pairs. `body` is RAW TEXT
43/// passthrough (JSON or otherwise — the gateway's answer classifies;
44/// GET/DELETE bodies allowed, curl parity).
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ApiCallRequest {
47    /// HTTP method (any RFC verb — reqwest validates the bytes).
48    pub method: String,
49    /// Absolute path on the gateway (leading `/`, no `?`, no host).
50    pub path: String,
51    /// Raw body text, ANY method (None = no body).
52    pub body: Option<String>,
53    /// Extra headers (auth-pattern names are refused pre-I/O).
54    pub headers: Vec<(String, String)>,
55    /// Query pairs (the ONE query mechanism — never `?` in `path`).
56    pub query: Vec<(String, String)>,
57}
58
59/// The gateway's answer: HTTP status plus the body VERBATIM
60/// ([`serde_json::value::RawValue`] — bytes preserved, key order
61/// kept, still valid JSON; serializes inline inside the envelope).
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct ApiCallData {
64    /// The HTTP status the gateway answered with.
65    pub status: u16,
66    /// The response body VERBATIM.
67    pub data: Box<serde_json::value::RawValue>,
68}
69
70/// Refuse user-supplied auth-pattern headers — pre-I/O, usage class
71/// (exit 2). The comparison is `trim().to_ascii_lowercase()` on the
72/// NAME only: HTTP header names are case-insensitive (Pitfall 3), and
73/// trimming beats whitespace-prefix smuggling. The refusal names the
74/// header and the profile-auth rule so the fix is obvious.
75pub fn refuse_auth_headers(headers: &[(String, String)]) -> Result<(), CoreError> {
76    for (name, _) in headers {
77        let normalized = name.trim().to_ascii_lowercase();
78        if REFUSED_AUTH_HEADERS.contains(&normalized.as_str()) {
79            return Err(CoreError::InvalidInput {
80                reason: format!(
81                    "header {name:?} is auth-pattern and refused — credentials come \
82                     from the profile (ign applies X-Ignition-API-Token itself); \
83                     pass only non-auth headers"
84                ),
85            });
86        }
87    }
88    Ok(())
89}
90
91/// Validate `--path` — pre-I/O, usage class (exit 2):
92///
93/// (a) must start with `/` (it joins onto the profile URL);
94/// (b) must not be host-shaped — a protocol-relative `//host/…`
95///     prefix or any string [`url::Url`] parses WITH a host
96///     (absolute/foreign URLs) is refused: `url_for`'s join would
97///     silently rebase the request off the profile's gateway, turning
98///     the CLI into a proxy;
99/// (c) must not embed `?` — the ONE query mechanism is the repeatable
100///     `--query k=v` flag (Pitfall 4: double-encoded query params are
101///     a silent wire corruption).
102pub fn validate_path(path: &str) -> Result<(), CoreError> {
103    // Host-shaped FIRST — the most specific diagnosis: an absolute or
104    // protocol-relative URL would make `url_for`'s join silently
105    // rebase the request off the profile's gateway (the CLI must not
106    // become a proxy), and that cause deserves its own refusal text
107    // even when the leading-slash rule would also have fired.
108    if path.starts_with("//")
109        || url::Url::parse(path).is_ok_and(|parsed| parsed.host_str().is_some())
110    {
111        return Err(CoreError::InvalidInput {
112            reason: format!(
113                "--path must be a path, not a URL ({path:?} carries a host) — the \
114                 request always goes to the profile's gateway"
115            ),
116        });
117    }
118    if !path.starts_with('/') {
119        return Err(CoreError::InvalidInput {
120            reason: format!(
121                "--path must start with '/' (it joins onto the profile's gateway URL): {path:?}"
122            ),
123        });
124    }
125    if path.contains('?') {
126        return Err(CoreError::InvalidInput {
127            reason: format!(
128                "--path must not embed a query string ({path:?}) — use the repeatable \
129                 --query k=v flag (the ONE query mechanism)"
130            ),
131        });
132    }
133    Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138    use super::{ApiCallRequest, REFUSED_AUTH_HEADERS, refuse_auth_headers, validate_path};
139    use crate::client::GatewayApi;
140    use crate::client::ReqwestGatewayApi;
141    use crate::config::{Credential, Secret};
142    use crate::error::CoreError;
143
144    // ---- refusal matrix (free function, Pitfall 3's full set) ----
145
146    #[test]
147    fn refusal_matrix_covers_case_and_whitespace_variants() {
148        let canonical = [("Authorization".to_string(), "Bearer x".to_string())];
149        let case_variant = [("AUTHORIZATION".to_string(), "Bearer x".to_string())];
150        let mixed_case = [("authorization".to_string(), "Bearer x".to_string())];
151        let token = [("x-ignition-api-token".to_string(), "name:key".to_string())];
152        let token_canonical = [("X-Ignition-API-Token".to_string(), "name:key".to_string())];
153        let cookie = [("Cookie".to_string(), "session=1".to_string())];
154        let whitespace = [("  Authorization ".to_string(), "Bearer x".to_string())];
155
156        for headers in [
157            &canonical,
158            &case_variant,
159            &mixed_case,
160            &token,
161            &token_canonical,
162            &cookie,
163            &whitespace,
164        ] {
165            let err = refuse_auth_headers(headers).expect_err("auth-pattern header refuses");
166            assert!(matches!(err, CoreError::InvalidInput { .. }), "{err}");
167            assert_eq!(err.code(), "invalid_input");
168            assert_eq!(err.exit_code(), 2, "usage class");
169            let text = err.to_string();
170            assert!(
171                text.contains("profile"),
172                "the refusal names the profile-auth rule: {text}"
173            );
174        }
175
176        // Non-auth headers pass.
177        refuse_auth_headers(&[("X-Custom-Thing".to_string(), "v".to_string())])
178            .expect("non-auth header passes");
179        refuse_auth_headers(&[]).expect("no headers passes");
180    }
181
182    /// The refused set is exactly the three documented names (a fourth
183    /// addition is a contract change — pin it loudly).
184    #[test]
185    fn refused_set_is_exactly_the_documented_three() {
186        assert_eq!(REFUSED_AUTH_HEADERS.len(), 3);
187        assert!(REFUSED_AUTH_HEADERS.contains(&"authorization"));
188        assert!(REFUSED_AUTH_HEADERS.contains(&"x-ignition-api-token"));
189        assert!(REFUSED_AUTH_HEADERS.contains(&"cookie"));
190    }
191
192    // ---- path matrix (free function, Pitfall 4's full set) ----
193
194    #[test]
195    fn path_matrix_refuses_bad_shapes_and_accepts_clean_paths() {
196        // Refusals, each with a specific reason.
197        let missing_slash = validate_path("data/api/v1/x").expect_err("missing slash refuses");
198        assert!(missing_slash.to_string().contains("'/'"), "{missing_slash}");
199
200        let absolute = validate_path("http://other/x").expect_err("absolute URL refuses");
201        assert!(absolute.to_string().contains("host"), "{absolute}");
202
203        let protocol_relative = validate_path("//host/x").expect_err("//host refuses");
204        assert!(
205            protocol_relative.to_string().contains("host"),
206            "{protocol_relative}"
207        );
208
209        let query_embedded = validate_path("/data/x?embed=1").expect_err("embedded ? refuses");
210        assert!(
211            query_embedded.to_string().contains("--query"),
212            "the ? refusal names the ONE query mechanism: {query_embedded}"
213        );
214
215        // Clean paths pass.
216        validate_path("/data/x").expect("clean single-segment path passes");
217        validate_path("/data/api/v1/gateway-info").expect("multi-segment path passes");
218        validate_path("/").expect("root path passes");
219    }
220
221    // ---- the pipeline over wiremock ----
222
223    fn token_client(base: &str) -> ReqwestGatewayApi {
224        ReqwestGatewayApi::for_tests(base, Some(Credential::Token(Secret::new("name:key"))))
225    }
226
227    /// THE verbatim path: a 2xx body rides through `RawValue` — the
228    /// odd key order survives (the binary contract test pins the same
229    /// property end-to-end; this is the unit-level proof).
230    #[tokio::test]
231    async fn success_body_rides_verbatim_with_status() {
232        let server = wiremock::MockServer::start().await;
233        wiremock::Mock::given(wiremock::matchers::method("GET"))
234            .and(wiremock::matchers::path("/data/api/v1/x"))
235            .respond_with(
236                wiremock::ResponseTemplate::new(200)
237                    .set_body_string(r#"{"zz_last": 1, "alpha_first": {"b": 2, "a": 1}}"#),
238            )
239            .expect(1)
240            .mount(&server)
241            .await;
242
243        let call = ApiCallRequest {
244            method: "GET".to_string(),
245            path: "/data/api/v1/x".to_string(),
246            body: None,
247            headers: vec![],
248            query: vec![],
249        };
250        let data = token_client(&server.uri())
251            .api_call(&call)
252            .await
253            .expect("2xx answers");
254        assert_eq!(data.status, 200);
255        assert_eq!(
256            data.data.get(),
257            r#"{"zz_last": 1, "alpha_first": {"b": 2, "a": 1}}"#
258        );
259    }
260
261    /// The request REACHES the gateway with the profile's auth header,
262    /// the query pairs appended, and the body riding a GET (curl
263    /// parity — decision 6).
264    #[tokio::test]
265    async fn request_rides_auth_query_and_get_body() {
266        let server = wiremock::MockServer::start().await;
267        wiremock::Mock::given(wiremock::matchers::method("GET"))
268            .and(wiremock::matchers::path("/data/api/v1/x"))
269            .and(wiremock::matchers::query_param("a", "1"))
270            .and(wiremock::matchers::query_param("b", "2"))
271            .and(wiremock::matchers::header(
272                "x-ignition-api-token",
273                "name:key",
274            ))
275            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("{}"))
276            .expect(1)
277            .mount(&server)
278            .await;
279
280        let call = ApiCallRequest {
281            method: "GET".to_string(),
282            path: "/data/api/v1/x".to_string(),
283            body: Some(r#"{"x":1}"#.to_string()),
284            headers: vec![("X-Custom".to_string(), "v".to_string())],
285            query: vec![
286                ("a".to_string(), "1".to_string()),
287                ("b".to_string(), "2".to_string()),
288            ],
289        };
290        token_client(&server.uri())
291            .api_call(&call)
292            .await
293            .expect("mock answers");
294
295        let requests = server.received_requests().await.expect("requests recorded");
296        assert_eq!(requests.len(), 1);
297        let body = String::from_utf8_lossy(&requests[0].body);
298        assert_eq!(body, r#"{"x":1}"#, "the GET carries the raw body verbatim");
299        let custom = requests[0]
300            .headers
301            .iter()
302            .find(|(name, _)| name.as_str() == "x-custom")
303            .map(|(_, value)| value.to_str().expect("ascii").to_string());
304        assert_eq!(custom.as_deref(), Some("v"), "the user header rode along");
305    }
306
307    /// A non-JSON 2xx body is the honest internal-class refusal with
308    /// the explanatory message (the README documents it — the binary
309    /// endpoints belong to the download pipelines).
310    #[tokio::test]
311    async fn non_json_2xx_body_refuses_internal() {
312        let server = wiremock::MockServer::start().await;
313        wiremock::Mock::given(wiremock::matchers::method("GET"))
314            .and(wiremock::matchers::path("/data/api/v1/x"))
315            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("this is not json"))
316            .expect(1)
317            .mount(&server)
318            .await;
319
320        let call = ApiCallRequest {
321            method: "GET".to_string(),
322            path: "/data/api/v1/x".to_string(),
323            body: None,
324            headers: vec![],
325            query: vec![],
326        };
327        let err = token_client(&server.uri())
328            .api_call(&call)
329            .await
330            .expect_err("non-JSON 2xx refuses");
331        assert!(matches!(err, CoreError::Internal(_)), "{err}");
332        assert_eq!(err.exit_code(), 1);
333        let text = err.to_string();
334        assert!(
335            text.contains("non-JSON body"),
336            "the message explains the contract: {text}"
337        );
338    }
339
340    /// A syntactically INVALID verb string refuses pre-I/O (usage
341    /// class), never a reqwest panic. (Arbitrary well-formed
342    /// extension methods stay accepted — reqwest's `Method` parser is
343    /// the validator; the gateway's answer classifies.)
344    #[tokio::test]
345    async fn invalid_method_refuses_invalid_input() {
346        let server = wiremock::MockServer::start().await;
347        for method in ["NOT A VERB", "GE\tT", ""] {
348            let call = ApiCallRequest {
349                method: method.to_string(),
350                path: "/data/x".to_string(),
351                body: None,
352                headers: vec![],
353                query: vec![],
354            };
355            let err = token_client(&server.uri())
356                .api_call(&call)
357                .await
358                .expect_err("invalid verb refuses");
359            assert!(
360                matches!(err, CoreError::InvalidInput { .. }),
361                "{method:?} refuses invalid_input: {err}"
362            );
363        }
364        // Zero requests: the refusal fired before the wire.
365        let requests = server.received_requests().await.expect("requests recorded");
366        assert!(
367            requests.is_empty(),
368            "no request may hit the wire: {requests:?}"
369        );
370    }
371}