Skip to main content

lmn_core/capture/
mod.rs

1//! Capture state for step chaining: extract values from HTTP responses and
2//! inject them into subsequent step requests.
3
4use std::collections::HashMap;
5
6use serde_json::Value;
7
8// ── CaptureDefinition ────────────────────────────────────────────────────────
9
10/// A single capture rule: extract a JSON path from the response body and store
11/// the result under `alias` in the per-iteration `CaptureState`.
12#[derive(Debug, Clone)]
13pub struct CaptureDefinition {
14    pub alias: String,
15    pub path: Vec<String>,
16}
17
18// ── CaptureState ─────────────────────────────────────────────────────────────
19
20/// Per-iteration mutable capture state. Created (or cleared) at the start of
21/// each iteration. No `Arc`, no `Mutex`, no cross-iteration leakage.
22#[derive(Debug, Default)]
23pub struct CaptureState {
24    values: HashMap<String, String>,
25}
26
27impl CaptureState {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    pub fn clear(&mut self) {
33        self.values.clear();
34    }
35
36    pub fn insert(&mut self, key: String, value: String) {
37        self.values.insert(key, value);
38    }
39
40    pub fn get(&self, key: &str) -> Option<&str> {
41        self.values.get(key).map(|s| s.as_str())
42    }
43}
44
45// ── parse_json_path ──────────────────────────────────────────────────────────
46
47/// Parses a `$.`-prefixed JSON path into a `Vec<String>` of key segments.
48///
49/// `"$.data.access_token"` → `["data", "access_token"]`.
50/// Only object key traversal is supported (no array indexing).
51pub fn parse_json_path(path: &str) -> Result<Vec<String>, String> {
52    let rest = path
53        .strip_prefix("$.")
54        .ok_or_else(|| format!("capture path must start with '$.' — got '{path}'"))?;
55
56    if rest.is_empty() {
57        return Err(format!("capture path is empty after '$.' — got '{path}'"));
58    }
59
60    let segments: Vec<String> = rest.split('.').map(|s| s.to_string()).collect();
61
62    if segments.iter().any(|s| s.is_empty()) {
63        return Err(format!(
64            "capture path contains empty segment — got '{path}'"
65        ));
66    }
67
68    Ok(segments)
69}
70
71// ── value_to_string ──────────────────────────────────────────────────────────
72
73/// Converts a `serde_json::Value` to a `String` for capture storage.
74///
75/// - `String(s)` → `s` (no wrapping quotes)
76/// - `Number(n)` → `n.to_string()`
77/// - `Bool(b)` → `b.to_string()`
78/// - `Null` → `None` (capture not inserted)
79/// - `Object` / `Array` → compact JSON via `serde_json::to_string()`
80///
81/// Control characters (`\r`, `\n`, `\0`) are stripped from the result to
82/// prevent HTTP header injection when captured values are injected into
83/// subsequent request headers. This is defense-in-depth — `reqwest` also
84/// rejects bare CRLF in header values, but we sanitize here to keep the
85/// invariant close to the data source.
86pub fn value_to_string(value: &Value) -> Option<String> {
87    let raw = match value {
88        Value::String(s) => Some(s.clone()),
89        Value::Number(n) => Some(n.to_string()),
90        Value::Bool(b) => Some(b.to_string()),
91        Value::Null => return None,
92        other => serde_json::to_string(other).ok(),
93    };
94    raw.map(sanitize_captured_value)
95}
96
97/// Strips control characters that could cause HTTP header injection or
98/// request smuggling if a captured value is later injected into headers.
99fn sanitize_captured_value(s: String) -> String {
100    if s.bytes().any(|b| b == b'\r' || b == b'\n' || b == b'\0') {
101        s.replace(['\r', '\n', '\0'], "")
102    } else {
103        s
104    }
105}
106
107// ── inject_captures ──────────────────────────────────────────────────────────
108
109/// Replaces all `{{capture.KEY}}` patterns in `text` with values from `state`.
110///
111/// Returns `Err` if a referenced key is missing from the state (the request
112/// would contain unresolved references and should not be sent).
113pub fn inject_captures(text: &str, state: &CaptureState) -> Result<String, String> {
114    let marker = "{{capture.";
115    let mut result = String::with_capacity(text.len());
116    let mut rest = text;
117
118    while let Some(start) = rest.find(marker) {
119        result.push_str(&rest[..start]);
120        let after_marker = &rest[start + marker.len()..];
121
122        let end = after_marker
123            .find("}}")
124            .ok_or_else(|| format!("unterminated capture placeholder in: {text}"))?;
125
126        let key = &after_marker[..end];
127
128        let value = state.get(key).ok_or_else(|| {
129            format!("capture key '{key}' not found in state — preceding step may have failed")
130        })?;
131
132        result.push_str(value);
133        rest = &after_marker[end + 2..];
134    }
135
136    result.push_str(rest);
137    Ok(result)
138}
139
140// ── inject_captures_into_headers ─────────────────────────────────────────────
141
142/// Applies capture injection to header **values** only (keys are untouched).
143pub fn inject_captures_into_headers(
144    headers: &[(String, String)],
145    state: &CaptureState,
146) -> Result<Vec<(String, String)>, String> {
147    headers
148        .iter()
149        .map(|(name, value)| {
150            let injected = inject_captures(value, state)?;
151            Ok((name.clone(), injected))
152        })
153        .collect()
154}
155
156// ── scan_capture_refs ────────────────────────────────────────────────────────
157
158/// Scans `text` for `{{capture.KEY}}` references and returns the keys.
159///
160/// Returns `Err` if an unterminated `{{capture.` is found (missing `}}`).
161/// This catches config typos at startup rather than at runtime.
162pub fn scan_capture_refs(text: &str) -> Result<Vec<String>, String> {
163    let marker = "{{capture.";
164    let mut refs = Vec::new();
165    let mut rest = text;
166
167    while let Some(start) = rest.find(marker) {
168        let after_marker = &rest[start + marker.len()..];
169        if let Some(end) = after_marker.find("}}") {
170            refs.push(after_marker[..end].to_string());
171            rest = &after_marker[end + 2..];
172        } else {
173            return Err(format!(
174                "unterminated capture placeholder: '{{{{capture.{}…'",
175                &after_marker[..after_marker.len().min(20)]
176            ));
177        }
178    }
179
180    Ok(refs)
181}
182
183// ── Tests ────────────────────────────────────────────────────────────────────
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use serde_json::json;
189
190    // ── parse_json_path ──────────────────────────────────────────────────────
191
192    #[test]
193    fn parse_json_path_simple() {
194        let path = parse_json_path("$.data.access_token").unwrap();
195        assert_eq!(path, vec!["data", "access_token"]);
196    }
197
198    #[test]
199    fn parse_json_path_single_segment() {
200        let path = parse_json_path("$.token").unwrap();
201        assert_eq!(path, vec!["token"]);
202    }
203
204    #[test]
205    fn parse_json_path_deep() {
206        let path = parse_json_path("$.a.b.c.d").unwrap();
207        assert_eq!(path, vec!["a", "b", "c", "d"]);
208    }
209
210    #[test]
211    fn parse_json_path_no_prefix() {
212        assert!(parse_json_path("data.token").is_err());
213    }
214
215    #[test]
216    fn parse_json_path_empty_after_prefix() {
217        assert!(parse_json_path("$.").is_err());
218    }
219
220    #[test]
221    fn parse_json_path_empty_segment() {
222        assert!(parse_json_path("$.a..b").is_err());
223    }
224
225    // ── value_to_string ──────────────────────────────────────────────────────
226
227    #[test]
228    fn value_to_string_string() {
229        assert_eq!(value_to_string(&json!("hello")), Some("hello".to_string()));
230    }
231
232    #[test]
233    fn value_to_string_number() {
234        assert_eq!(value_to_string(&json!(42)), Some("42".to_string()));
235    }
236
237    #[test]
238    fn value_to_string_float() {
239        assert_eq!(value_to_string(&json!(2.72)), Some("2.72".to_string()));
240    }
241
242    #[test]
243    fn value_to_string_bool() {
244        assert_eq!(value_to_string(&json!(true)), Some("true".to_string()));
245    }
246
247    #[test]
248    fn value_to_string_null() {
249        assert_eq!(value_to_string(&json!(null)), None);
250    }
251
252    #[test]
253    fn value_to_string_object() {
254        let val = json!({"a": 1});
255        let result = value_to_string(&val).unwrap();
256        assert!(result.contains("\"a\""));
257        assert!(result.contains("1"));
258    }
259
260    #[test]
261    fn value_to_string_array() {
262        let val = json!([1, 2, 3]);
263        let result = value_to_string(&val).unwrap();
264        assert_eq!(result, "[1,2,3]");
265    }
266
267    // ── inject_captures ──────────────────────────────────────────────────────
268
269    #[test]
270    fn inject_captures_single_replacement() {
271        let mut state = CaptureState::new();
272        state.insert("token".to_string(), "abc123".to_string());
273        let result = inject_captures("Bearer {{capture.token}}", &state).unwrap();
274        assert_eq!(result, "Bearer abc123");
275    }
276
277    #[test]
278    fn inject_captures_multiple_replacements() {
279        let mut state = CaptureState::new();
280        state.insert("token".to_string(), "tok".to_string());
281        state.insert("user_id".to_string(), "42".to_string());
282        let result = inject_captures("{{capture.token}} for {{capture.user_id}}", &state).unwrap();
283        assert_eq!(result, "tok for 42");
284    }
285
286    #[test]
287    fn inject_captures_no_placeholders() {
288        let state = CaptureState::new();
289        let result = inject_captures("no captures here", &state).unwrap();
290        assert_eq!(result, "no captures here");
291    }
292
293    #[test]
294    fn inject_captures_missing_key_returns_err() {
295        let state = CaptureState::new();
296        let result = inject_captures("{{capture.missing}}", &state);
297        assert!(result.is_err());
298        assert!(result.unwrap_err().contains("missing"));
299    }
300
301    #[test]
302    fn inject_captures_unterminated_returns_err() {
303        let state = CaptureState::new();
304        let result = inject_captures("{{capture.broken", &state);
305        assert!(result.is_err());
306    }
307
308    // ── inject_captures_into_headers ─────────────────────────────────────────
309
310    #[test]
311    fn inject_captures_into_headers_replaces_values() {
312        let mut state = CaptureState::new();
313        state.insert("token".to_string(), "secret".to_string());
314        let headers = vec![
315            (
316                "Authorization".to_string(),
317                "Bearer {{capture.token}}".to_string(),
318            ),
319            ("X-Static".to_string(), "no-capture".to_string()),
320        ];
321        let result = inject_captures_into_headers(&headers, &state).unwrap();
322        assert_eq!(result[0].1, "Bearer secret");
323        assert_eq!(result[1].1, "no-capture");
324    }
325
326    #[test]
327    fn inject_captures_into_headers_missing_key_returns_err() {
328        let state = CaptureState::new();
329        let headers = vec![("Auth".to_string(), "{{capture.nope}}".to_string())];
330        assert!(inject_captures_into_headers(&headers, &state).is_err());
331    }
332
333    // ── scan_capture_refs ────────────────────────────────────────────────────
334
335    #[test]
336    fn scan_capture_refs_extracts_keys() {
337        let refs = scan_capture_refs("{{capture.token}} and {{capture.user_id}}").unwrap();
338        assert_eq!(refs, vec!["token", "user_id"]);
339    }
340
341    #[test]
342    fn scan_capture_refs_no_captures() {
343        let refs = scan_capture_refs("no captures here").unwrap();
344        assert!(refs.is_empty());
345    }
346
347    #[test]
348    fn scan_capture_refs_unterminated_is_error() {
349        let err = scan_capture_refs("{{capture.ok}} then {{capture.broken").unwrap_err();
350        assert!(err.contains("unterminated"), "{err}");
351    }
352
353    // ── CaptureState ─────────────────────────────────────────────────────────
354
355    // ── sanitize_captured_value ───────────────────────────────────────────
356
357    #[test]
358    fn value_to_string_strips_crlf() {
359        let val = json!("evil\r\nX-Injected: true");
360        assert_eq!(
361            value_to_string(&val),
362            Some("evilX-Injected: true".to_string())
363        );
364    }
365
366    #[test]
367    fn value_to_string_strips_null_byte() {
368        let val = json!("hello\0world");
369        assert_eq!(value_to_string(&val), Some("helloworld".to_string()));
370    }
371
372    #[test]
373    fn value_to_string_clean_string_unchanged() {
374        let val = json!("clean-value");
375        assert_eq!(value_to_string(&val), Some("clean-value".to_string()));
376    }
377
378    // ── CaptureState ─────────────────────────────────────────────────────
379
380    #[test]
381    fn capture_state_clear() {
382        let mut state = CaptureState::new();
383        state.insert("a".to_string(), "1".to_string());
384        assert!(state.get("a").is_some());
385        state.clear();
386        assert!(state.get("a").is_none());
387    }
388}