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}