Skip to main content

tapes_capture/
envelope_fixtures.rs

1//! The shared envelope fixture corpus vendored at
2//! `vendor/tapes-envelope-fixtures/` (source: tapes `fixtures/envelope/` — see
3//! that directory's `SOURCE.md`), as a reader plus this crate's producer-side
4//! oracle.
5//!
6//! **Available on crate feature `envelope-fixtures` only.** The feature is off
7//! by default and is meant for `[dev-dependencies]`; nothing in this module is
8//! compiled into a consumer's production build unless it asks. The crate's
9//! documentation is built with every feature on, so this module renders there
10//! whether or not you have enabled it.
11//!
12//! # The corpus is vendored, in three places at once
13//!
14//! The corpus is copied into **every** implementation of the contract — this
15//! crate, and each parser in each other language — and all copies must move
16//! together from one upstream revision. A copy that moves alone is a test suite
17//! going green against bytes no other implementation has ever seen, which is
18//! exactly the failure the corpus exists to prevent.
19//!
20//! `DIGEST` is what makes "the same corpus" checkable rather than asserted:
21//! sort the case files by base name, feed `"<basename>  <sha256>\n"` for each
22//! into SHA-256, and compare the result. The recipe is deliberately trivial so
23//! that each language restates it in a few lines instead of sharing an
24//! implementation that would itself have to be vendored. `corpus-seal` in this
25//! repository's makefile recomputes it, and a test does the same on every
26//! `cargo test`.
27//!
28//! The corpus pins the `X-Tapes-*` header ↔ session-envelope contract. This
29//! crate is the **producer**: it turns a resolved session identity into the on-wire
30//! header set. The parsers on the other side (tapes-extproc's
31//! `ParseSessionEnvelope`, the tapes ingest reader) table-test against the same
32//! files. Drift between the two halves is otherwise invisible until a captured
33//! session lands mis-attributed, so the oracle below makes the corpus
34//! executable here rather than merely documentary.
35//!
36//! ### Why the reader is public
37//!
38//! A capture client composes envelopes of its own — from an inbound envelope it
39//! chose to trust, from a session file it resolved, from request headers it
40//! parsed — and each composition is a place its bytes can drift from the
41//! contract. Those clients could only table-test against this corpus by
42//! re-implementing the loader, the case-direction rules, and the
43//! metadata-as-JSON comparison; a second reader is a second set of decisions
44//! about what a case *means*, which is exactly the drift the corpus exists to
45//! prevent. So the reader ships behind the `envelope-fixtures` feature, off by
46//! default: a consumer enables it under `[dev-dependencies]` and gets the same
47//! corpus, read the same way.
48//!
49//! This is a **test utility**. [`load_cases`] and [`decode_metadata`] panic on a
50//! missing, truncated, or malformed corpus rather than returning a `Result` —
51//! a broken corpus is a broken checkout, not a runtime condition to handle, and
52//! a `Result` here would invite a consumer to swallow it and silently test
53//! nothing. Do not call these from production paths.
54//!
55//! ### Which cases this side owns
56//!
57//! Each case declares a `direction`:
58//!
59//! * `roundtrip` — `encode(envelope) == headers`. Asserted here.
60//! * `encode` — a *lossy* producer transform (session-name truncation,
61//!   oversize-metadata drop, percent-encoding a path the reader won't decode
62//!   back). The logical input is the case's `encode_from`, not its `envelope`;
63//!   `encode(encode_from) == headers` is asserted here.
64//! * `decode` — parser-only cases: malformed or missing-header input that a
65//!   well-behaved producer never emits (empty parent header, metadata that
66//!   isn't valid base64, a missing harness-id). Skipped here **by design** —
67//!   there is no encode side to assert. The parser oracles cover them.
68//!
69//! ### What is compared
70//!
71//! Only the `x-tapes-*` headers. Every case's header set also carries the
72//! server-trusted identity headers of the deployment that authored the corpus
73//! (`x-paper-auth-org-id` / `x-paper-auth-subject`): an authenticating edge sets
74//! those from validated credential claims, so a producer must never forge them.
75//! The test asserts that this producer emits none of them.
76//!
77//! The metadata header is compared as *decoded JSON*, not as a base64 string.
78//! JSON key ordering is not part of the contract, so byte-comparing the encoded
79//! blob would pin an implementation detail of whichever serializer produced the
80//! fixture. Every other header is compared byte for byte.
81
82#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
83
84use std::collections::BTreeMap;
85use std::path::PathBuf;
86
87use base64::Engine;
88use base64::engine::general_purpose::URL_SAFE_NO_PAD;
89use http::HeaderMap;
90use serde::Deserialize;
91
92use super::{HARNESS_ID_UNKNOWN, TapesAttribution};
93
94/// A case's `direction`: which half of the contract it asserts.
95///
96/// Read this rather than string-matching `direction`, so a consumer's skip
97/// rule and this crate's cannot disagree about what a case claims.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum Direction {
100    /// `encode(envelope) == headers`, and the parsers get the same envelope
101    /// back. Both halves assert it.
102    Roundtrip,
103    /// A *lossy* producer transform (session-name truncation, oversize-metadata
104    /// drop, percent-encoding a path the reader won't decode back). The logical
105    /// input is [`FixtureCase::encode_from`], not `envelope`.
106    Encode,
107    /// Parser-only: malformed or missing-header input a well-behaved producer
108    /// never emits. There is no encode side to assert.
109    Decode,
110}
111
112/// One `cases/*.json` file. Unknown fields (`grounding`, `notes`, `error`, …)
113/// are ignored: they carry provenance for humans, not assertions for this side.
114#[derive(Debug, Clone, Deserialize)]
115#[non_exhaustive]
116pub struct FixtureCase {
117    /// The case's name, as its filename declares it. Use it in assertion
118    /// messages — a failure that names the case is one lookup from the file.
119    pub name: String,
120    /// Which half of the contract this case asserts, as the raw string. Prefer
121    /// [`FixtureCase::direction`].
122    pub direction: String,
123    /// The complete header set for the case, including the server-trusted
124    /// identity headers a producer must never emit — an authenticating edge
125    /// sets those, and the corpus carries the spelling its authoring deployment
126    /// uses.
127    pub headers: BTreeMap<String, String>,
128    /// The envelope the headers correspond to.
129    pub envelope: FixtureEnvelope,
130    /// Present only on lossy (`direction: encode`) cases: the logical envelope
131    /// a producer starts from, before truncation / drop / percent-encoding.
132    #[serde(default)]
133    pub encode_from: Option<FixtureEnvelope>,
134}
135
136impl FixtureCase {
137    /// This case's [`Direction`].
138    ///
139    /// # Panics
140    ///
141    /// If the case declares a direction this crate does not know. An
142    /// unrecognised direction means the corpus grew a contract this side has
143    /// not been taught, and silently skipping it would let the new contract go
144    /// unasserted — the failure mode the corpus exists to prevent.
145    #[must_use]
146    pub fn direction(&self) -> Direction {
147        match self.direction.as_str() {
148            "roundtrip" => Direction::Roundtrip,
149            "encode" => Direction::Encode,
150            "decode" => Direction::Decode,
151            other => panic!("{}: unknown direction {other:?}", self.name),
152        }
153    }
154
155    /// The envelope a producer starts from: [`Self::encode_from`] on a lossy
156    /// case, the case's own envelope otherwise.
157    ///
158    /// # Panics
159    ///
160    /// If a non-`encode` case carries an `encode_from`. The corpus reserves
161    /// that field for lossy cases, so a `roundtrip` case carrying one is
162    /// claiming `encode(envelope) == headers` while handing the producer a
163    /// different input. Whichever of the two it means, the case is not saying
164    /// it — better to fail than to silently prefer one.
165    #[must_use]
166    pub fn logical_envelope(&self) -> &FixtureEnvelope {
167        assert!(
168            self.encode_from.is_none() || self.direction() == Direction::Encode,
169            "{}: encode_from is reserved for lossy `encode` cases, but direction is {:?}",
170            self.name,
171            self.direction,
172        );
173        self.encode_from.as_ref().unwrap_or(&self.envelope)
174    }
175
176    /// The `x-tapes-*` subset of this case's expected headers — what a
177    /// producer must emit, with the server-trusted headers excluded.
178    #[must_use]
179    pub fn expected_tapes_headers(&self) -> BTreeMap<String, String> {
180        self.headers
181            .iter()
182            .filter(|(name, _)| name.starts_with("x-tapes-"))
183            .map(|(name, value)| (name.clone(), value.clone()))
184            .collect()
185    }
186}
187
188/// The envelope side of a case. `org_id` / `auth_subject` are deliberately not
189/// modelled — they are not the producer's to emit (see module docs).
190#[derive(Debug, Clone, Deserialize)]
191#[non_exhaustive]
192pub struct FixtureEnvelope {
193    /// Harness id; absent means the case expects the `unknown` sentinel.
194    #[serde(default)]
195    pub harness_id: Option<String>,
196    /// Opaque harness-side session id.
197    #[serde(default)]
198    pub harness_session_id: Option<String>,
199    /// Harness version string.
200    #[serde(default)]
201    pub harness_version: Option<String>,
202    /// Harness working directory, decoded.
203    #[serde(default)]
204    pub cwd: Option<String>,
205    /// User-given session name, decoded and untruncated.
206    #[serde(default)]
207    pub name: Option<String>,
208    /// Fork-parent's harness session id.
209    #[serde(default)]
210    pub parent_harness_session_id: Option<String>,
211    /// Free-form harness metadata, as JSON rather than base64url.
212    #[serde(default)]
213    pub harness_metadata: Option<serde_json::Value>,
214}
215
216/// The directory holding the vendored `cases/*.json`.
217///
218/// Resolved from this crate's `CARGO_MANIFEST_DIR`, so a consumer that enables
219/// the feature reads the corpus out of the crate's own checkout — the same
220/// bytes this crate tests against, at whatever revision the consumer pinned.
221#[must_use]
222pub fn cases_dir() -> PathBuf {
223    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
224        .join("vendor")
225        .join("tapes-envelope-fixtures")
226        .join("cases")
227}
228
229/// Load every vendored case, sorted by path so failures report in a stable
230/// order regardless of directory iteration order.
231///
232/// # Panics
233///
234/// If the corpus directory is unreadable, empty, or holds a file that is not a
235/// valid case. See the module docs: a broken corpus is a broken checkout.
236#[must_use]
237pub fn load_cases() -> Vec<FixtureCase> {
238    let dir = cases_dir();
239    let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
240        .unwrap_or_else(|e| panic!("read {}: {e}", dir.display()))
241        .map(|entry| entry.expect("read dir entry").path())
242        .filter(|p| p.extension().is_some_and(|e| e == "json"))
243        .collect();
244    paths.sort();
245
246    assert!(
247        !paths.is_empty(),
248        "no envelope fixture cases under {} — run scripts/sync-envelope-fixtures.sh <tapes-checkout>",
249        dir.display(),
250    );
251
252    paths
253        .iter()
254        .map(|p| {
255            let bytes = std::fs::read(p).unwrap_or_else(|e| panic!("read {}: {e}", p.display()));
256            serde_json::from_slice(&bytes).unwrap_or_else(|e| panic!("parse {}: {e}", p.display()))
257        })
258        .collect()
259}
260
261/// Build the attribution a producer would hold for `env`.
262///
263/// This constructs [`TapesAttribution`] field-by-field rather than going
264/// through `from_session()` / `codex_session()` because the corpus spans harnesses
265/// those constructors don't cover (`pi`) and field combinations they can't
266/// express. The named constructors are what production uses; this exercises the
267/// serialization they all funnel into.
268#[must_use]
269pub fn attribution_from(env: &FixtureEnvelope) -> TapesAttribution {
270    let metadata = match &env.harness_metadata {
271        Some(serde_json::Value::Object(map)) => map.clone(),
272        // A non-object metadata value is a parser-side concern; no producer
273        // path can construct one (the field is typed as a JSON object).
274        _ => serde_json::Map::new(),
275    };
276
277    TapesAttribution {
278        harness_id: env
279            .harness_id
280            .clone()
281            .unwrap_or_else(|| HARNESS_ID_UNKNOWN.to_owned()),
282        session_id: env.harness_session_id.clone(),
283        version: env.harness_version.clone(),
284        cwd: env.cwd.clone(),
285        name: env.name.clone(),
286        parent_sid: env.parent_harness_session_id.clone(),
287        metadata,
288    }
289}
290
291/// The `x-tapes-*` subset of a header map, as plain strings.
292///
293/// The comparison unit for every producer assertion: pair it with
294/// [`FixtureCase::expected_tapes_headers`].
295///
296/// # Panics
297///
298/// If an emitted header value is not visible ASCII. That is a producer bug —
299/// every field is percent-encoded or base64url before it reaches a header.
300#[must_use]
301pub fn tapes_headers(headers: &HeaderMap) -> BTreeMap<String, String> {
302    headers
303        .iter()
304        .filter(|(name, _)| name.as_str().starts_with("x-tapes-"))
305        .map(|(name, value)| {
306            let v = value
307                .to_str()
308                .expect("emitted header value must be visible ASCII")
309                .to_owned();
310            (name.as_str().to_owned(), v)
311        })
312        .collect()
313}
314
315/// Decode a base64url(no-pad) metadata header into JSON.
316///
317/// Metadata is compared as decoded JSON, never as a base64 string: JSON key
318/// ordering is not part of the contract, so byte-comparing the encoded blob
319/// would pin an implementation detail of whichever serializer produced the
320/// fixture.
321///
322/// # Panics
323///
324/// If `encoded` is not base64url(no-pad) of a JSON document.
325#[must_use]
326pub fn decode_metadata(encoded: &str) -> serde_json::Value {
327    let raw = URL_SAFE_NO_PAD
328        .decode(encoded)
329        .unwrap_or_else(|e| panic!("metadata header is not base64url(no-pad): {e}"));
330    serde_json::from_slice(&raw)
331        .unwrap_or_else(|e| panic!("metadata header does not decode to JSON: {e}"))
332}
333
334// --- this crate's producer-side oracle over the corpus above ---------
335//
336// These stay `#[cfg(test)]` while the reader they use is public: a consumer
337// wants the corpus and the reading rules, not this crate's assertions about
338// its own producer.
339
340#[cfg(test)]
341use crate::envelope::{
342    X_TAPES_HARNESS_METADATA, inject_tapes_attribution, inject_unattributed_envelope,
343};
344
345#[cfg(test)]
346#[test]
347fn produces_every_encodable_fixture_case() {
348    let cases = load_cases();
349
350    // A corpus that silently lost most of its files would otherwise "pass" on
351    // whatever survived.
352    assert!(
353        cases.len() >= 15,
354        "only {} envelope fixture cases loaded; the vendored corpus looks truncated",
355        cases.len(),
356    );
357
358    let mut produced = 0_usize;
359    let mut skipped = Vec::new();
360
361    for case in &cases {
362        // Skipping is driven purely by the case's own `direction`, never by a
363        // hardcoded list here — a new case is covered the moment it is synced.
364        // `direction()` also rejects a direction this crate has not been
365        // taught, so a corpus that grows a new contract fails loudly here
366        // rather than quietly skipping it.
367        if case.direction() == Direction::Decode {
368            skipped.push(case.name.clone());
369            continue;
370        }
371
372        // A lossy case encodes from `encode_from`; a round-tripping one from
373        // its own envelope, and `logical_envelope` rejects a case that
374        // declares both inconsistently.
375        let logical = case.logical_envelope();
376
377        let mut headers = HeaderMap::new();
378        inject_tapes_attribution(&mut headers, attribution_from(logical))
379            .unwrap_or_else(|e| panic!("{}: inject failed: {e:?}", case.name));
380
381        let got = tapes_headers(&headers);
382        let want = case.expected_tapes_headers();
383
384        // Compare the header *sets* first: a missing or surplus header is a
385        // clearer failure than a per-value mismatch on one of them.
386        let got_names: Vec<&String> = got.keys().collect();
387        let want_names: Vec<&String> = want.keys().collect();
388        assert_eq!(
389            got_names, want_names,
390            "{}: emitted header set does not match the fixture",
391            case.name,
392        );
393
394        for (name, want_value) in &want {
395            let got_value = &got[name];
396            if name == X_TAPES_HARNESS_METADATA {
397                assert_eq!(
398                    decode_metadata(got_value),
399                    decode_metadata(want_value),
400                    "{}: {name} decodes to different JSON",
401                    case.name,
402                );
403            } else {
404                assert_eq!(got_value, want_value, "{}: {name}", case.name);
405            }
406        }
407
408        // The producer must not forge the server-trusted identity headers; the
409        // cloud edge sets those from validated JWT claims.
410        for name in headers.keys() {
411            assert!(
412                !name.as_str().starts_with("x-paper-auth-"),
413                "{}: producer emitted server-trusted header {name}",
414                case.name,
415            );
416        }
417
418        produced += 1;
419    }
420
421    preserves_complete_inbound_envelopes(&cases);
422
423    assert_eq!(
424        produced + skipped.len(),
425        cases.len(),
426        "every case must be either produced or explicitly skipped",
427    );
428    assert!(
429        produced >= 10,
430        "only {produced} cases exercised the producer; skipped: {skipped:?}",
431    );
432}
433
434/// The `unknown` harness-id is a distinct code path in
435/// [`inject_tapes_attribution`] — it returns after one header rather than
436/// walking the budget. The corpus's `unknown-bare` case pins the result, but
437/// only in aggregate with everything else; assert the path directly so a
438/// regression names itself.
439#[cfg(test)]
440#[test]
441fn unknown_harness_case_emits_only_the_required_header() {
442    let case = load_cases()
443        .into_iter()
444        .find(|c| c.name == "unknown-bare")
445        .expect("corpus contains the unknown-bare case");
446
447    let mut headers = HeaderMap::new();
448    inject_tapes_attribution(&mut headers, attribution_from(&case.envelope)).unwrap();
449
450    let got = tapes_headers(&headers);
451    assert_eq!(got.len(), 1, "unknown harness attaches exactly one header");
452    assert_eq!(got["x-tapes-harness-id"], HARNESS_ID_UNKNOWN);
453}
454
455/// Cases whose inbound headers already carry a complete envelope pin a
456/// different contract from the rest of the corpus: the producer must leave
457/// them alone.
458///
459/// The producer loop above cannot cover it. It reconstructs headers from the
460/// parsed envelope via `inject_tapes_attribution`, which is the wrong entry
461/// point — preservation is decided in `inject_unattributed_envelope`, by
462/// `has_complete_inbound_envelope`, before any attribution is built. A
463/// regression that broke complete-envelope detection would leave every
464/// assertion above green while the producer silently overwrote a caller's identity
465/// with `unknown`.
466///
467/// So drive the real entry point with the case's own inbound headers — the
468/// unattributed caller this contract exists for — and require the X-Tapes-*
469/// set to come back untouched.
470///
471/// Selection is by shape, not by name: any case whose headers carry a usable
472/// harness id and session id is a preservation case, so a future one is
473/// covered the moment it is synced.
474#[cfg(test)]
475fn preserves_complete_inbound_envelopes(cases: &[FixtureCase]) {
476    let mut checked = 0;
477
478    for case in cases {
479        let inbound: BTreeMap<String, String> = case
480            .headers
481            .iter()
482            .map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
483            .collect();
484
485        let harness_id = inbound
486            .get("x-tapes-harness-id")
487            .map(|v| v.trim())
488            .filter(|v| !v.is_empty() && *v != HARNESS_ID_UNKNOWN);
489        let session_id = inbound
490            .get("x-tapes-harness-session-id")
491            .map(|v| v.trim())
492            .filter(|v| !v.is_empty());
493        if harness_id.is_none() || session_id.is_none() {
494            continue;
495        }
496
497        let mut headers = HeaderMap::new();
498        for (name, value) in &case.headers {
499            let parsed_name = match http::HeaderName::from_bytes(name.as_bytes()) {
500                Ok(n) => n,
501                // A case may deliberately carry a header an HTTP stack would
502                // reject; those are parser fixtures, not producer ones.
503                Err(_) => continue,
504            };
505            let Ok(parsed_value) = http::HeaderValue::from_str(value) else {
506                continue;
507            };
508            headers.insert(parsed_name, parsed_value);
509        }
510        let before = tapes_headers(&headers);
511        if before.get("x-tapes-harness-id").map(String::as_str) != harness_id {
512            // The header did not survive HeaderMap construction, so this case
513            // is not exercising the preservation path.
514            continue;
515        }
516
517        inject_unattributed_envelope(&mut headers).unwrap_or_else(|e| {
518            panic!("{}: inject_unattributed_envelope failed: {e:?}", case.name)
519        });
520
521        assert_eq!(
522            tapes_headers(&headers),
523            before,
524            "{}: a complete inbound envelope must be preserved as-is",
525            case.name,
526        );
527        checked += 1;
528    }
529
530    assert!(
531        checked > 0,
532        "no case exercised the complete-inbound-envelope preservation path; \
533         the corpus should retain at least one (e.g. pi-complete)",
534    );
535}