Skip to main content

faucet_source_xml/
decode.rs

1//! Response-decode pipeline (#515).
2//!
3//! A declarative chain applied to the raw response **body** before record
4//! extraction, so a source can consume payloads that aren't plain JSON —
5//! including files embedded in a JSON/SOAP envelope:
6//!
7//! ```yaml
8//! decode:
9//!   - extract: "runReportResponse.runReportReturn.reportBytes"  # element text out of the SOAP body
10//!   - base64                               # base64 → bytes
11//!   - unzip: { member: "*.csv" }           # or `gunzip`
12//!   - parse: { format: xlsx, header_row: 4 }
13//! ```
14//!
15//! Steps compose left-to-right over a byte buffer; the terminal `parse` step
16//! turns the bytes into records. Without an explicit `parse`, the bytes are
17//! parsed as JSON.
18//!
19//! Note: unlike the REST source's decode pipeline, the XML source's `extract`
20//! step selects an **element's text** by a dot-path (namespace-insensitive,
21//! trailing-match) rather than a JSONPath — the body here is XML/SOAP.
22
23use base64::Engine;
24use faucet_core::FaucetError;
25use jsonpath_rust::JsonPath;
26use quick_xml::events::Event;
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::{Map, Value};
30use std::io::{Cursor, Read};
31
32/// A byte-chain step with no parameters (`- base64`, `- gunzip`).
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
34#[serde(rename_all = "snake_case")]
35pub enum SimpleStep {
36    /// Base64-decode the (UTF-8 text) buffer into bytes.
37    Base64,
38    /// Gzip-decompress the buffer.
39    Gunzip,
40}
41
42/// Select a member of a zip archive.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
44#[serde(deny_unknown_fields)]
45pub struct UnzipSpec {
46    /// Glob (`*.csv`, `prefix*`, `*mid*`, or an exact name) selecting the member.
47    /// When omitted, the first file entry is used.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub member: Option<String>,
50}
51
52/// Final-parse format for a decode chain.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
54#[serde(rename_all = "snake_case")]
55pub enum ParseFormat {
56    /// JSON (default).
57    #[default]
58    Json,
59    /// Delimited text.
60    Csv,
61    /// Excel workbook (requires the crate's `excel` feature).
62    Xlsx,
63    /// XML → JSON.
64    Xml,
65}
66
67fn default_has_headers() -> bool {
68    true
69}
70
71/// Parse the decoded bytes into records.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
73#[serde(deny_unknown_fields)]
74pub struct ParseSpec {
75    /// Output format.
76    pub format: ParseFormat,
77    /// JSONPath selecting the record array (json/xml). When omitted, an array
78    /// body becomes the records and an object becomes a single record.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub records_path: Option<String>,
81    /// CSV delimiter byte (default `,`).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub delimiter: Option<u8>,
84    /// Whether the first CSV row is a header (default `true`).
85    #[serde(default = "default_has_headers")]
86    pub has_headers: bool,
87    /// Excel worksheet name / index-as-string (default: first).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub sheet: Option<String>,
90    /// 0-based Excel header row (default `0`).
91    #[serde(default)]
92    pub header_row: usize,
93}
94
95/// One step of the decode chain.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
97#[serde(untagged)]
98pub enum DecodeStep {
99    /// Parameterless byte step (`base64` / `gunzip`).
100    Simple(SimpleStep),
101    /// Pull a (string) field out of a JSON body first.
102    Extract {
103        /// JSONPath to a string field whose value becomes the buffer.
104        extract: String,
105    },
106    /// Select a member from a zip archive.
107    Unzip {
108        /// Member selector.
109        unzip: UnzipSpec,
110    },
111    /// Terminal parse into records.
112    Parse {
113        /// Parse options.
114        parse: ParseSpec,
115    },
116}
117
118/// Run the decode chain over the response body, returning records.
119pub async fn run_decode(body: &[u8], steps: &[DecodeStep]) -> Result<Vec<Value>, FaucetError> {
120    let mut buf = body.to_vec();
121    for step in steps {
122        match step {
123            DecodeStep::Extract { extract } => {
124                // XML source variant: `extract` is a dot-path to an element whose
125                // text content becomes the buffer (e.g. pull a base64 file blob
126                // out of a SOAP `<reportBytes>` element). The path is matched by
127                // its trailing element names, namespace-prefix-insensitive.
128                let s = xml_extract_text(&buf, extract).ok_or_else(|| {
129                    FaucetError::Source(format!(
130                        "decode `extract`: '{extract}' matched no element text in the XML body"
131                    ))
132                })?;
133                buf = s.into_bytes();
134            }
135            DecodeStep::Simple(SimpleStep::Base64) => {
136                let text = std::str::from_utf8(&buf).map_err(|e| {
137                    FaucetError::Source(format!("decode `base64`: buffer is not UTF-8 text: {e}"))
138                })?;
139                buf = base64::engine::general_purpose::STANDARD
140                    .decode(text.trim())
141                    .map_err(|e| FaucetError::Source(format!("decode `base64`: {e}")))?;
142            }
143            DecodeStep::Simple(SimpleStep::Gunzip) => {
144                let mut out = Vec::new();
145                flate2::read::GzDecoder::new(Cursor::new(&buf))
146                    .read_to_end(&mut out)
147                    .map_err(|e| FaucetError::Source(format!("decode `gunzip`: {e}")))?;
148                buf = out;
149            }
150            DecodeStep::Unzip { unzip } => {
151                buf = unzip_member(&buf, unzip.member.as_deref())?;
152            }
153            DecodeStep::Parse { parse } => {
154                return parse_records(&buf, parse).await;
155            }
156        }
157    }
158    // No explicit `parse` → default to JSON.
159    parse_records(
160        &buf,
161        &ParseSpec {
162            format: ParseFormat::Json,
163            records_path: None,
164            delimiter: None,
165            has_headers: true,
166            sheet: None,
167            header_row: 0,
168        },
169    )
170    .await
171}
172
173/// Concatenated text of the first element whose ancestor stack ends with the
174/// dot-path segments (namespace-prefix-insensitive). Used by the XML `extract`
175/// decode step to pull a blob (e.g. a base64-encoded file) out of a SOAP body —
176/// e.g. `runReportResponse.runReportReturn.reportBytes`.
177pub(crate) fn xml_extract_text(bytes: &[u8], dot_path: &str) -> Option<String> {
178    let want: Vec<String> = dot_path
179        .split('.')
180        .filter(|s| !s.is_empty())
181        .map(|s| s.rsplit(':').next().unwrap_or(s).to_string())
182        .collect();
183    if want.is_empty() {
184        return None;
185    }
186    let text = std::str::from_utf8(bytes).ok()?;
187    let mut reader = quick_xml::Reader::from_str(text);
188    let local = |name: &[u8]| -> String {
189        String::from_utf8_lossy(name)
190            .rsplit(':')
191            .next()
192            .unwrap_or_default()
193            .to_string()
194    };
195    let ends_with = |stack: &[String]| -> bool {
196        stack.len() >= want.len() && stack[stack.len() - want.len()..] == want[..]
197    };
198    let mut stack: Vec<String> = Vec::new();
199    let mut capture_depth: Option<usize> = None;
200    let mut out = String::new();
201    loop {
202        match reader.read_event().ok()? {
203            Event::Eof => break,
204            Event::Start(e) => {
205                stack.push(local(e.name().as_ref()));
206                if capture_depth.is_none() && ends_with(&stack) {
207                    capture_depth = Some(stack.len());
208                    out.clear();
209                }
210            }
211            Event::Empty(e) => {
212                stack.push(local(e.name().as_ref()));
213                if capture_depth.is_none() && ends_with(&stack) {
214                    return Some(String::new());
215                }
216                stack.pop();
217            }
218            Event::End(_) => {
219                if capture_depth == Some(stack.len()) {
220                    return Some(out);
221                }
222                stack.pop();
223            }
224            Event::Text(t) if capture_depth.is_some() => {
225                if let Ok(s) = t.unescape() {
226                    out.push_str(&s);
227                }
228            }
229            Event::CData(t) if capture_depth.is_some() => {
230                out.push_str(&String::from_utf8_lossy(&t));
231            }
232            _ => {}
233        }
234    }
235    None
236}
237
238/// Minimal glob: `*` matches any run. Supports `*.ext`, `prefix*`, `*mid*`,
239/// `*a*b*`, and exact names.
240fn glob_match(pattern: &str, name: &str) -> bool {
241    if !pattern.contains('*') {
242        return pattern == name;
243    }
244    let parts: Vec<&str> = pattern.split('*').collect();
245    let mut pos = 0usize;
246    for (i, part) in parts.iter().enumerate() {
247        if part.is_empty() {
248            continue;
249        }
250        if i == 0 {
251            if !name[pos..].starts_with(part) {
252                return false;
253            }
254            pos += part.len();
255        } else if i == parts.len() - 1 {
256            return name[pos..].ends_with(part);
257        } else {
258            match name[pos..].find(part) {
259                Some(idx) => pos += idx + part.len(),
260                None => return false,
261            }
262        }
263    }
264    true
265}
266
267fn unzip_member(bytes: &[u8], member: Option<&str>) -> Result<Vec<u8>, FaucetError> {
268    let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
269        .map_err(|e| FaucetError::Source(format!("decode `unzip`: not a valid zip: {e}")))?;
270    // Resolve the member index first (immutable name scan), then read it.
271    let mut chosen: Option<usize> = None;
272    for i in 0..archive.len() {
273        let f = archive
274            .by_index(i)
275            .map_err(|e| FaucetError::Source(format!("decode `unzip`: {e}")))?;
276        if !f.is_file() {
277            continue;
278        }
279        let matches = match member {
280            Some(pat) => glob_match(pat, f.name()),
281            None => true, // first file
282        };
283        if matches {
284            chosen = Some(i);
285            break;
286        }
287    }
288    let idx = chosen.ok_or_else(|| {
289        FaucetError::Source(format!(
290            "decode `unzip`: no member matched {}",
291            member
292                .map(|m| format!("'{m}'"))
293                .unwrap_or_else(|| "any".into())
294        ))
295    })?;
296    let mut f = archive
297        .by_index(idx)
298        .map_err(|e| FaucetError::Source(format!("decode `unzip`: {e}")))?;
299    let mut out = Vec::new();
300    f.read_to_end(&mut out)
301        .map_err(|e| FaucetError::Source(format!("decode `unzip`: reading member: {e}")))?;
302    Ok(out)
303}
304
305async fn parse_records(bytes: &[u8], spec: &ParseSpec) -> Result<Vec<Value>, FaucetError> {
306    match spec.format {
307        ParseFormat::Json => {
308            let v: Value = serde_json::from_slice(bytes)
309                .map_err(|e| FaucetError::Source(format!("decode `parse` json: {e}")))?;
310            Ok(records_from_value(v, spec.records_path.as_deref()))
311        }
312        ParseFormat::Csv => {
313            crate::format::parse_csv(bytes, spec.delimiter.unwrap_or(b','), spec.has_headers)
314                .await
315                .map_err(|e| FaucetError::Source(format!("decode `parse` csv: {e}")))
316        }
317        ParseFormat::Xlsx => {
318            crate::format::parse_excel(bytes, spec.sheet.as_deref(), spec.header_row)
319                .map_err(|e| FaucetError::Source(format!("decode `parse` xlsx: {e}")))
320        }
321        ParseFormat::Xml => {
322            let v = xml_to_json(bytes)?;
323            Ok(records_from_value(v, spec.records_path.as_deref()))
324        }
325    }
326}
327
328/// Turn a JSON value into records: apply `records_path` if given, else an array
329/// becomes the records and any other value becomes a single record.
330fn records_from_value(v: Value, records_path: Option<&str>) -> Vec<Value> {
331    match records_path {
332        Some(path) => v
333            .query(path)
334            .ok()
335            .map(|ms| ms.into_iter().cloned().collect())
336            .unwrap_or_default(),
337        None => match v {
338            Value::Array(a) => a,
339            other => vec![other],
340        },
341    }
342}
343
344/// Compact XML → JSON: each element becomes an object of its children; repeated
345/// child tags become arrays; attributes are `@name`; text is `#text` (or the
346/// value directly when an element has only text).
347fn xml_to_json(bytes: &[u8]) -> Result<Value, FaucetError> {
348    let text = std::str::from_utf8(bytes)
349        .map_err(|e| FaucetError::Source(format!("decode `parse` xml: not UTF-8: {e}")))?;
350    let mut reader = quick_xml::Reader::from_str(text);
351    // A stack of (object, text-accumulator) frames; index 0 is the document root.
352    let mut stack: Vec<(Map<String, Value>, String)> = vec![(Map::new(), String::new())];
353
354    fn attrs(e: &quick_xml::events::BytesStart) -> Map<String, Value> {
355        let mut m = Map::new();
356        for a in e.attributes().flatten() {
357            let k = String::from_utf8_lossy(a.key.as_ref())
358                .rsplit(':')
359                .next()
360                .unwrap_or_default()
361                .to_string();
362            if let Ok(v) = a.unescape_value() {
363                m.insert(format!("@{k}"), Value::String(v.to_string()));
364            }
365        }
366        m
367    }
368    fn local(name: &[u8]) -> String {
369        String::from_utf8_lossy(name)
370            .rsplit(':')
371            .next()
372            .unwrap_or_default()
373            .to_string()
374    }
375    fn insert_child(parent: &mut Map<String, Value>, key: String, val: Value) {
376        match parent.get_mut(&key) {
377            Some(Value::Array(arr)) => arr.push(val),
378            Some(existing) => {
379                let prev = existing.take();
380                parent.insert(key, Value::Array(vec![prev, val]));
381            }
382            None => {
383                parent.insert(key, val);
384            }
385        }
386    }
387    fn finish(obj: Map<String, Value>, text: String) -> Value {
388        let trimmed = text.trim();
389        if obj.is_empty() {
390            Value::String(trimmed.to_string())
391        } else {
392            let mut obj = obj;
393            if !trimmed.is_empty() {
394                obj.insert("#text".to_string(), Value::String(trimmed.to_string()));
395            }
396            Value::Object(obj)
397        }
398    }
399
400    loop {
401        match reader
402            .read_event()
403            .map_err(|e| FaucetError::Source(format!("decode `parse` xml: {e}")))?
404        {
405            Event::Eof => break,
406            Event::Start(e) => stack.push((attrs(&e), String::new())),
407            Event::Empty(e) => {
408                let name = local(e.name().as_ref());
409                let val = finish(attrs(&e), String::new());
410                let top = stack.last_mut().expect("root frame present");
411                insert_child(&mut top.0, name, val);
412            }
413            Event::End(e) => {
414                let (obj, text) = stack.pop().expect("matched start frame");
415                let name = local(e.name().as_ref());
416                let val = finish(obj, text);
417                let top = stack.last_mut().expect("root frame present");
418                insert_child(&mut top.0, name, val);
419            }
420            Event::Text(t) => {
421                if let Ok(s) = t.unescape() {
422                    stack.last_mut().expect("root frame present").1.push_str(&s);
423                }
424            }
425            Event::CData(t) => {
426                stack
427                    .last_mut()
428                    .expect("root frame present")
429                    .1
430                    .push_str(&String::from_utf8_lossy(&t));
431            }
432            _ => {}
433        }
434    }
435    let (root, _) = stack.pop().unwrap_or_default();
436    Ok(Value::Object(root))
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use serde_json::json;
443
444    fn parse_json() -> ParseSpec {
445        ParseSpec {
446            format: ParseFormat::Json,
447            records_path: None,
448            delimiter: None,
449            has_headers: true,
450            sheet: None,
451            header_row: 0,
452        }
453    }
454
455    #[test]
456    fn glob_matches_common_patterns() {
457        assert!(glob_match("*.csv", "report.csv"));
458        assert!(!glob_match("*.csv", "report.txt"));
459        assert!(glob_match("data*", "data_2024.csv"));
460        assert!(glob_match("*part*", "x_part_1"));
461        assert!(glob_match("exact.csv", "exact.csv"));
462        assert!(!glob_match("exact.csv", "other.csv"));
463        assert!(glob_match("*a*b*", "xxaxxbxx"));
464        assert!(!glob_match("*a*b*", "xxbxxaxx"));
465    }
466
467    #[tokio::test]
468    async fn extract_xml_base64_json_chain() {
469        // A SOAP-ish XML envelope holding a base64-encoded JSON array in an
470        // element — the XML `extract` step pulls the element text out.
471        let inner = br#"[{"id":1},{"id":2}]"#;
472        let b64 = base64::engine::general_purpose::STANDARD.encode(inner);
473        let body = format!("<Envelope><Body><d><payload>{b64}</payload></d></Body></Envelope>");
474        let steps = vec![
475            DecodeStep::Extract {
476                extract: "d.payload".into(),
477            },
478            DecodeStep::Simple(SimpleStep::Base64),
479            DecodeStep::Parse {
480                parse: parse_json(),
481            },
482        ];
483        let recs = run_decode(body.as_bytes(), &steps).await.unwrap();
484        assert_eq!(recs.len(), 2);
485        assert_eq!(recs[1]["id"], 2);
486    }
487
488    #[tokio::test]
489    async fn extract_xml_namespaced_trailing_match() {
490        // Namespace prefixes are stripped and the path matches by trailing
491        // segments, so a SOAP-qualified element is found by its local names.
492        let body = "<soap:Envelope><soap:Body><ns:runReportResponse>\
493            <ns:runReportReturn><ns:reportBytes>SGk=</ns:reportBytes>\
494            </ns:runReportReturn></ns:runReportResponse></soap:Body></soap:Envelope>";
495        // "SGk=" → "Hi"; parse the decoded text as a headerless CSV so the
496        // single value lands as a field we can assert on.
497        let steps = [
498            DecodeStep::Extract {
499                extract: "runReportResponse.runReportReturn.reportBytes".into(),
500            },
501            DecodeStep::Simple(SimpleStep::Base64),
502            DecodeStep::Parse {
503                parse: ParseSpec {
504                    format: ParseFormat::Csv,
505                    has_headers: false,
506                    ..parse_json()
507                },
508            },
509        ];
510        let recs = run_decode(body.as_bytes(), &steps).await.unwrap();
511        assert_eq!(recs.len(), 1);
512        assert_eq!(recs[0]["column_0"], "Hi");
513    }
514
515    #[tokio::test]
516    async fn gunzip_csv_chain() {
517        use flate2::{Compression, write::GzEncoder};
518        use std::io::Write;
519        let csv = b"a,b\n1,2\n3,4\n";
520        let mut enc = GzEncoder::new(Vec::new(), Compression::default());
521        enc.write_all(csv).unwrap();
522        let gz = enc.finish().unwrap();
523        let steps = vec![
524            DecodeStep::Simple(SimpleStep::Gunzip),
525            DecodeStep::Parse {
526                parse: ParseSpec {
527                    format: ParseFormat::Csv,
528                    ..parse_json()
529                },
530            },
531        ];
532        let recs = run_decode(&gz, &steps).await.unwrap();
533        assert_eq!(recs.len(), 2);
534        assert_eq!(recs[0]["a"], "1");
535        assert_eq!(recs[1]["b"], "4");
536    }
537
538    #[tokio::test]
539    async fn unzip_member_glob_chain() {
540        use std::io::Write;
541        use zip::write::SimpleFileOptions;
542        let mut cur = Cursor::new(Vec::new());
543        {
544            let mut zw = zip::ZipWriter::new(&mut cur);
545            let opts = SimpleFileOptions::default();
546            zw.start_file("notes.txt", opts).unwrap();
547            zw.write_all(b"ignore").unwrap();
548            zw.start_file("data.csv", opts).unwrap();
549            zw.write_all(b"x\n9\n").unwrap();
550            zw.finish().unwrap();
551        }
552        let zip_bytes = cur.into_inner();
553        let steps = vec![
554            DecodeStep::Unzip {
555                unzip: UnzipSpec {
556                    member: Some("*.csv".into()),
557                },
558            },
559            DecodeStep::Parse {
560                parse: ParseSpec {
561                    format: ParseFormat::Csv,
562                    ..parse_json()
563                },
564            },
565        ];
566        let recs = run_decode(&zip_bytes, &steps).await.unwrap();
567        assert_eq!(recs.len(), 1);
568        assert_eq!(recs[0]["x"], "9");
569    }
570
571    #[tokio::test]
572    async fn default_parse_is_json_and_records_path_works() {
573        let body = br#"{"items":[{"n":1},{"n":2}]}"#;
574        // No parse step → default json, whole object is one record.
575        let recs = run_decode(body, &[]).await.unwrap();
576        assert_eq!(recs.len(), 1);
577        // With records_path → the array.
578        let steps = vec![DecodeStep::Parse {
579            parse: ParseSpec {
580                records_path: Some("$.items[*]".into()),
581                ..parse_json()
582            },
583        }];
584        let recs = run_decode(body, &steps).await.unwrap();
585        assert_eq!(recs.len(), 2);
586    }
587
588    #[test]
589    fn xml_to_json_handles_repeated_elements_and_attrs() {
590        let xml = br#"<root><row id="1"><n>a</n></row><row id="2"><n>b</n></row></root>"#;
591        let v = xml_to_json(xml).unwrap();
592        let rows = &v["root"]["row"];
593        assert!(rows.is_array());
594        assert_eq!(rows[0]["@id"], "1");
595        assert_eq!(rows[0]["n"], "a");
596        assert_eq!(rows[1]["n"], "b");
597    }
598
599    #[tokio::test]
600    async fn xml_parse_with_records_path() {
601        let xml = br#"<root><row><n>a</n></row><row><n>b</n></row></root>"#;
602        let steps = vec![DecodeStep::Parse {
603            parse: ParseSpec {
604                format: ParseFormat::Xml,
605                records_path: Some("$.root.row[*]".into()),
606                ..parse_json()
607            },
608        }];
609        let recs = run_decode(xml, &steps).await.unwrap();
610        assert_eq!(recs.len(), 2);
611        assert_eq!(recs[1]["n"], "b");
612    }
613
614    #[tokio::test]
615    async fn base64_on_non_utf8_errors() {
616        let steps = vec![DecodeStep::Simple(SimpleStep::Base64)];
617        assert!(run_decode(&[0xff, 0xfe], &steps).await.is_err());
618    }
619
620    #[tokio::test]
621    async fn extract_missing_field_errors() {
622        let steps = vec![DecodeStep::Extract {
623            extract: "$.nope".into(),
624        }];
625        assert!(run_decode(br#"{"a":1}"#, &steps).await.is_err());
626    }
627
628    #[tokio::test]
629    async fn unzip_no_match_errors() {
630        use std::io::Write;
631        use zip::write::SimpleFileOptions;
632        let mut cur = Cursor::new(Vec::new());
633        {
634            let mut zw = zip::ZipWriter::new(&mut cur);
635            zw.start_file("a.txt", SimpleFileOptions::default())
636                .unwrap();
637            zw.write_all(b"x").unwrap();
638            zw.finish().unwrap();
639        }
640        let steps = vec![DecodeStep::Unzip {
641            unzip: UnzipSpec {
642                member: Some("*.csv".into()),
643            },
644        }];
645        assert!(run_decode(&cur.into_inner(), &steps).await.is_err());
646    }
647
648    #[test]
649    fn decode_step_deserializes_mixed_forms() {
650        let steps: Vec<DecodeStep> = serde_json::from_value(json!([
651            "base64",
652            "gunzip",
653            { "extract": "$.x" },
654            { "unzip": { "member": "*.csv" } },
655            { "parse": { "format": "csv" } }
656        ]))
657        .unwrap();
658        assert_eq!(steps.len(), 5);
659        assert!(matches!(steps[0], DecodeStep::Simple(SimpleStep::Base64)));
660        assert!(matches!(steps[1], DecodeStep::Simple(SimpleStep::Gunzip)));
661        assert!(matches!(steps[2], DecodeStep::Extract { .. }));
662        assert!(matches!(steps[3], DecodeStep::Unzip { .. }));
663        assert!(matches!(steps[4], DecodeStep::Parse { .. }));
664    }
665}