regent-httpsig 0.5.1__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
regent_httpsig/fastapi.py CHANGED
@@ -13,9 +13,12 @@ Usage::
13
13
  if sig:
14
14
  ... # sig.agent == "https://chatgpt.com", sig.keyid, sig.trusted
15
15
 
16
- ``SignatureDep`` is enrichment: ``None`` when absent/invalid, never raises.
17
- ``RequiredSignatureDep`` is authentication: a coded 401 tells the agent exactly
18
- how to sign.
16
+ ``SignatureDep`` is enrichment: ``None`` when absent/invalid, never raises
17
+ (``SignatureErrorDep`` tells you why). ``RequiredSignatureDep`` is
18
+ authentication: a signed request that fails is answered ``401`` with the
19
+ ``Signature-Error`` header of draft-hardt-httpbis-signature-key (the code
20
+ AAuth -11 §11.3.4 assigns), an unsigned one with ``AAuth-Requirement:
21
+ requirement=agent-token`` (-11 §6.1).
19
22
 
20
23
  Proxy note: the signer signed the PUBLIC url. Behind a reverse proxy this
21
24
  dependency rebuilds it from ``X-Forwarded-Proto`` + ``Host`` — make sure your
@@ -41,17 +44,34 @@ from regent_httpsig.budget import (
41
44
  InvalidBudgetClaim,
42
45
  MeterKey,
43
46
  Reservation,
47
+ TokenRevoked,
44
48
  UnitMismatch,
45
49
  )
46
- from regent_httpsig.sfv import build_aauth_budget_header, build_aauth_requirement
47
- from regent_httpsig.verify import HttpsigVerifier, VerifiedSignature
50
+ from regent_httpsig.sfv import (
51
+ build_aauth_budget_header,
52
+ build_aauth_requirement,
53
+ build_person_token_requirement,
54
+ )
55
+ from regent_httpsig.verify import (
56
+ AAUTH_AUTH_TYP,
57
+ AAUTH_PERSON_TYP,
58
+ HttpsigVerifier,
59
+ VerificationError,
60
+ VerifiedSignature,
61
+ )
48
62
 
49
63
  __all__ = [
50
64
  "BudgetMiddleware",
51
65
  "RequiredSignatureDep",
52
66
  "SignatureDep",
67
+ "SignatureErrorDep",
68
+ "VerificationError",
53
69
  "VerifiedSignature",
54
70
  "attach",
71
+ "get_signature",
72
+ "get_signature_error",
73
+ "require_server_signature",
74
+ "signature_error_response",
55
75
  ]
56
76
 
57
77
  _STATE_ATTR = "regent_httpsig_verifier"
@@ -73,49 +93,128 @@ def _public_url(request: Request) -> str:
73
93
  return f"{scheme}://{host}{path}{query}"
74
94
 
75
95
 
76
- async def get_signature(request: Request) -> VerifiedSignature | None:
77
- """Optional verification: zero-cost without a Signature header, never raises."""
96
+ def _verifier_of(request: Request) -> HttpsigVerifier:
78
97
  verifier: HttpsigVerifier | None = getattr(request.app.state, _STATE_ATTR, None)
79
98
  if verifier is None:
80
99
  raise RuntimeError(
81
100
  "regent-httpsig verifier not attached — call "
82
101
  "regent_httpsig.fastapi.attach(app, HttpsigVerifier()) at startup"
83
102
  )
84
- if "signature" not in request.headers:
85
- return None
86
- cached = getattr(request.state, "regent_httpsig_result", "unset")
103
+ return verifier
104
+
105
+
106
+ async def _run_verifier(
107
+ verifier: HttpsigVerifier, request: Request,
108
+ ) -> VerifiedSignature | VerificationError | None:
109
+ """Verify once per request; both outcomes are cached on ``request.state``."""
110
+ cached: Any = getattr(request.state, "regent_httpsig_outcome", "unset")
87
111
  if cached != "unset":
88
- return cached # type: ignore[return-value]
89
- result = await verifier.verify(
90
- request.method, _public_url(request), dict(request.headers)
91
- )
92
- request.state.regent_httpsig_result = result
93
- return result
112
+ return cached if isinstance(cached, VerifiedSignature | VerificationError) else None
113
+ outcome: VerifiedSignature | VerificationError | None = None
114
+ if "signature" in request.headers:
115
+ # The -11 body rule (content-digest covered and matching) applies to the
116
+ # Signature-Key path; Web Bot Auth has no such rule and needs no body.
117
+ body = await request.body() if "signature-key" in request.headers else None
118
+ outcome = await verifier.verify_detailed(
119
+ request.method, _public_url(request), dict(request.headers), body,
120
+ )
121
+ request.state.regent_httpsig_outcome = outcome
122
+ request.state.regent_httpsig_result = (
123
+ outcome if isinstance(outcome, VerifiedSignature) else None)
124
+ request.state.regent_httpsig_error = (
125
+ outcome if isinstance(outcome, VerificationError) else None)
126
+ return outcome
127
+
128
+
129
+ def signature_error_response(
130
+ error: VerificationError, *, code: str | None = None, requirement: str | None = None,
131
+ ) -> JSONResponse:
132
+ """``401`` + ``Signature-Error`` (+ ``Accept-Signature-*``) with the
133
+ problem+json body of draft-hardt-httpbis-signature-key §5. ``requirement``
134
+ adds an ``AAuth-Requirement`` header; ``code`` adds a legacy ``code`` member."""
135
+ headers = error.headers()
136
+ if requirement:
137
+ headers["AAuth-Requirement"] = requirement
138
+ body = error.problem(401)
139
+ if code:
140
+ body["code"] = code
141
+ return JSONResponse(status_code=401, content=body, headers=headers,
142
+ media_type="application/problem+json")
143
+
144
+
145
+ def _repair_requirement(error: VerificationError) -> str | None:
146
+ """-11 §11.12.5 / budgets "auth token expired": a revoked or expired person
147
+ or auth token is repaired by a fresh person token; nothing repairs an agent
148
+ token (the agent obtains a new one from its provider)."""
149
+ if error.code in ("revoked_jwt", "expired_jwt") and error.token_typ in (
150
+ AAUTH_PERSON_TYP, AAUTH_AUTH_TYP,
151
+ ):
152
+ return build_person_token_requirement()
153
+ return None
154
+
155
+
156
+ async def get_signature(request: Request) -> VerifiedSignature | None:
157
+ """Optional verification: zero-cost without a Signature header, never raises."""
158
+ outcome = await _run_verifier(_verifier_of(request), request)
159
+ return outcome if isinstance(outcome, VerifiedSignature) else None
160
+
161
+
162
+ async def get_signature_error(request: Request) -> VerificationError | None:
163
+ """Why a signed request did not verify (``None`` when unsigned or verified)."""
164
+ outcome = await _run_verifier(_verifier_of(request), request)
165
+ return outcome if isinstance(outcome, VerificationError) else None
94
166
 
95
167
 
96
168
  async def require_signature(request: Request) -> VerifiedSignature:
97
169
  """Hard requirement: a fully verified signature, or a 401 that tells the
98
- agent exactly how to sign."""
99
- sig = await get_signature(request)
100
- if sig is None:
101
- raise HTTPException(
102
- status_code=401,
103
- detail={
104
- "code": "SIGNATURE_REQUIRED",
105
- "message": (
106
- "Sign this request with RFC 9421 HTTP Message Signatures: either "
107
- 'Web Bot Auth (tag="web-bot-auth", Ed25519 key published at '
108
- "{your-origin}/.well-known/http-message-signatures-directory, "
109
- "Signature-Agent header naming that origin) or AAuth (agent_token "
110
- "in the Signature-Key header, signature bound to its cnf.jwk)."
111
- ),
112
- },
113
- )
114
- return sig
170
+ agent exactly what went wrong (``Signature-Error``) or what to present
171
+ (``AAuth-Requirement: requirement=agent-token``)."""
172
+ outcome = await _run_verifier(_verifier_of(request), request)
173
+ if isinstance(outcome, VerifiedSignature):
174
+ return outcome
175
+ if isinstance(outcome, VerificationError):
176
+ headers = outcome.headers()
177
+ requirement = _repair_requirement(outcome)
178
+ if requirement:
179
+ headers["AAuth-Requirement"] = requirement
180
+ raise HTTPException(status_code=401, headers=headers,
181
+ detail={**outcome.problem(401), "code": "SIGNATURE_INVALID"})
182
+ raise HTTPException(
183
+ status_code=401,
184
+ headers={"AAuth-Requirement": "requirement=agent-token"},
185
+ detail={
186
+ "code": "SIGNATURE_REQUIRED",
187
+ "message": (
188
+ "Sign this request with RFC 9421 HTTP Message Signatures: either "
189
+ "AAuth (your agent token in the Signature-Key header under the jwt "
190
+ "scheme, signature over @method @authority @path signature-key bound "
191
+ 'to its cnf.jwk) or Web Bot Auth (tag="web-bot-auth", Ed25519 key '
192
+ "published at {your-origin}/.well-known/http-message-signatures-directory, "
193
+ "Signature-Agent header naming that origin)."
194
+ ),
195
+ },
196
+ )
197
+
198
+
199
+ async def require_server_signature(request: Request) -> VerifiedSignature:
200
+ """For endpoints servers call (usage, revocation): the caller must sign
201
+ under the ``jwks_uri`` scheme (-11 §11.3.2); the body is covered. A
202
+ failure is ``401`` + ``Signature-Error``."""
203
+ verifier = _verifier_of(request)
204
+ body = await request.body()
205
+ outcome = await verifier.verify_server(
206
+ request.method, _public_url(request), dict(request.headers), body,
207
+ )
208
+ if isinstance(outcome, VerifiedSignature):
209
+ return outcome
210
+ raise HTTPException(status_code=401, headers=outcome.headers(),
211
+ detail={**outcome.problem(401), "code": "SIGNATURE_INVALID"})
115
212
 
116
213
 
117
214
  SignatureDep = Depends(get_signature)
215
+ SignatureErrorDep = Depends(get_signature_error)
118
216
  RequiredSignatureDep = Depends(require_signature)
217
+ RequiredServerSignatureDep = Depends(require_server_signature)
119
218
 
120
219
 
121
220
  # ── AAuth Budgets enforcement (draft-hardt-aauth-budgets) ────────────────────
@@ -132,20 +231,17 @@ async def _maybe_await(value: Any) -> Any:
132
231
 
133
232
  class BudgetMiddleware(BaseHTTPMiddleware):
134
233
  """Meter budget-carrying requests: reserve the maximum cost atomically,
135
- serve, commit the actual, release the difference, and answer with an
136
- ``AAuth-Budget`` header. The full checklist a resource owes the draft —
137
- pricing excepted, which is the one thing only the resource can know.
234
+ serve, commit the actual cost, answer with ``AAuth-Budget``.
138
235
 
139
- Usage::
236
+ ::
140
237
 
141
- meter = InMemoryMeter()
142
238
  app.add_middleware(
143
239
  BudgetMiddleware,
144
240
  verifier=HttpsigVerifier(HttpsigConfig(
145
241
  resource_url="https://api.example",
146
242
  trusted_ps={"https://ps.example": "https://ps.example/jwks.json"},
147
243
  )),
148
- meter=meter,
244
+ meter=InMemoryMeter(),
149
245
  price_fn=lambda request: PRICES.get(request.url.path),
150
246
  )
151
247
 
@@ -156,6 +252,12 @@ class BudgetMiddleware(BaseHTTPMiddleware):
156
252
  - ``require=False`` (default) lets requests without a budget envelope pass
157
253
  through untouched — run per-decision authorization for them instead.
158
254
  ``require=True`` refuses them with 401 + ``AAuth-Requirement``.
255
+ - A *signed* request that does not verify is refused with ``401`` +
256
+ ``Signature-Error`` (-11 §11.3.4) whatever ``require`` says. A revoked or
257
+ expired auth token is ``revoked_jwt`` / ``expired_jwt`` plus
258
+ ``AAuth-Requirement: requirement=person-token`` (-11 §11.12.5) — no
259
+ resource token, no ``AAuth-Budget``; its final consumption record reaches
260
+ the issuer through the usage endpoint.
159
261
  - Error responses (4xx/5xx) release the reservation — nothing was served,
160
262
  the envelope is not charged.
161
263
  - ``resource_token_provider(key, record)`` (optional) mints the resource
@@ -164,6 +266,10 @@ class BudgetMiddleware(BaseHTTPMiddleware):
164
266
  the PRESENTED token's ``{"jti", "consumed"}`` (draft §The Consumption
165
267
  Record — one record, two members) or ``None`` when nothing was metered
166
268
  against it yet.
269
+ - Wire the verifier to the meter's revocation record
270
+ (``HttpsigVerifier(..., is_revoked=meter.is_revoked)``) so a revoked
271
+ token is named as revoked before it is metered; the middleware checks the
272
+ meter too, for verifiers wired otherwise.
167
273
  """
168
274
 
169
275
  def __init__(
@@ -190,14 +296,10 @@ class BudgetMiddleware(BaseHTTPMiddleware):
190
296
  if max_cost is None:
191
297
  return await call_next(request)
192
298
 
193
- sig = await self._verified(request)
194
- if sig is not None and sig.expired:
195
- # §Budget Exhaustion, "auth token expired": the base protocol's plain
196
- # 401 — and the resource token we attach carries the presented
197
- # token's FINAL consumption record (stated at/after its exp), which
198
- # is what lets its issuer settle the allocation exactly. Without
199
- # this challenge an issuer never sees a final figure at all.
200
- return await self._expired_challenge(sig)
299
+ outcome = await _run_verifier(self._verifier, request)
300
+ if isinstance(outcome, VerificationError):
301
+ return self._signature_error(outcome)
302
+ sig = outcome
201
303
  envelope: BudgetClaim | None = None
202
304
  if sig is not None:
203
305
  try:
@@ -216,6 +318,9 @@ class BudgetMiddleware(BaseHTTPMiddleware):
216
318
  str(sig.claims.get("sub", "")),
217
319
  str(sig.claims.get("aud", "")),
218
320
  )
321
+ is_revoked = getattr(self._meter, "is_revoked", None)
322
+ if is_revoked is not None and await is_revoked(key[0], jti):
323
+ return self._revoked()
219
324
  try:
220
325
  # sig.keyid is the RFC 7638 thumbprint of the token's cnf key (jkt) —
221
326
  # recorded so refusal-time consumption records are scoped to the
@@ -228,18 +333,23 @@ class BudgetMiddleware(BaseHTTPMiddleware):
228
333
  return self._refusal(reason="insufficient-budget", envelope=envelope,
229
334
  remaining=0, key=key)
230
335
 
231
- outcome = await self._meter.reserve(key, jti, int(max_cost))
232
- if isinstance(outcome, InsufficientBudget):
233
- reason = "budget-exhausted" if outcome.exhausted else "insufficient-budget"
336
+ outcome_r = await self._meter.reserve(key, jti, int(max_cost))
337
+ if isinstance(outcome_r, TokenRevoked):
338
+ # §Token Scope / -11 §11.12.5: say it was revoked, ask for a person
339
+ # token, carry no resource token. The final record (once nothing is
340
+ # in flight, AAuth #151) is the usage endpoint's to report.
341
+ return self._revoked()
342
+ if isinstance(outcome_r, InsufficientBudget):
343
+ reason = "budget-exhausted" if outcome_r.exhausted else "insufficient-budget"
234
344
  return await self._refusal_with_token(
235
- reason=reason, envelope=envelope, remaining=outcome.remaining,
345
+ reason=reason, envelope=envelope, remaining=outcome_r.remaining,
236
346
  key=key, jti=jti,
237
347
  # `required` rides only on insufficient-budget: what THIS
238
348
  # request needed, so the agent can lower its bound and retry.
239
349
  required=int(max_cost) if reason == "insufficient-budget" else None,
240
350
  )
241
351
 
242
- reservation: Reservation = outcome
352
+ reservation: Reservation = outcome_r
243
353
  try:
244
354
  response = await call_next(request)
245
355
  except Exception:
@@ -286,69 +396,42 @@ class BudgetMiddleware(BaseHTTPMiddleware):
286
396
 
287
397
  def _commit_after_stream(self, request: Request, response: Response,
288
398
  reservation: Reservation, max_cost: int) -> None:
289
- """Wrap the body iterator: commit when the stream ends (the handler may
290
- set ``request.state.budget_cost`` while streaming), release the unspent
291
- remainder; a broken stream commits the full hold — conservative, per
292
- the reservation-timeout rule."""
293
- inner = response.body_iterator # type: ignore[attr-defined]
399
+ """Wrap the body iterator so the reservation is committed (at the
400
+ handler's actual cost if it set one mid-stream, else the full hold)
401
+ when the stream ends, and released if it breaks before a byte is sent."""
402
+ meter = self._meter
403
+ original = response.body_iterator # type: ignore[attr-defined]
294
404
 
295
405
  async def metered() -> Any:
296
- ok = False
406
+ sent = False
297
407
  try:
298
- async for chunk in inner:
408
+ async for chunk in original:
409
+ sent = True
299
410
  yield chunk
300
- ok = True
301
- finally:
302
- actual = getattr(request.state, "budget_cost", None)
303
- cost = int(actual) if (ok and actual is not None) else max_cost
304
- await self._meter.commit(reservation, cost)
411
+ except BaseException:
412
+ await (meter.commit(reservation, max_cost) if sent
413
+ else meter.release(reservation))
414
+ raise
415
+ actual = getattr(request.state, "budget_cost", None)
416
+ await meter.commit(reservation,
417
+ int(actual) if actual is not None else max_cost)
305
418
 
306
419
  response.body_iterator = metered() # type: ignore[attr-defined]
307
420
 
308
421
  # ── helpers ──────────────────────────────────────────────────────────────
309
422
 
310
- async def _verified(self, request: Request) -> VerifiedSignature | None:
311
- cached = getattr(request.state, "regent_httpsig_result", "unset")
312
- if cached != "unset":
313
- return cached # type: ignore[return-value]
314
- result = None
315
- if "signature" in request.headers:
316
- result = await self._verifier.verify(
317
- request.method, _public_url(request), dict(request.headers),
318
- allow_expired_auth_token=True,
319
- )
320
- # An expired token is never handed to the app: the middleware answers
321
- # it with the final-record challenge and nothing downstream runs. The
322
- # per-request cache still must not carry it — a later dependency
323
- # reading the cache would otherwise see a token that is not valid.
324
- request.state.regent_httpsig_result = (
325
- None if result is not None and result.expired else result)
326
- return result
327
-
328
- async def _expired_challenge(self, sig: VerifiedSignature) -> Response:
329
- jti = str(sig.claims.get("jti") or "")
330
- key: MeterKey = (
331
- str(sig.claims.get("iss", "")),
332
- str(sig.claims.get("sub", "")),
333
- str(sig.claims.get("aud", "")),
334
- )
335
- token: str | None = None
336
- if self._resource_token is not None and jti:
337
- try:
338
- record = await self._meter.consumed_record(key, jti)
339
- token = await _maybe_await(self._resource_token(key, record))
340
- except Exception: # noqa: BLE001 — the challenge must not fail on the extras
341
- logger.warning("resource_token_provider failed", exc_info=True)
342
- return JSONResponse(
343
- status_code=401,
344
- content={
345
- "code": "AUTH_TOKEN_EXPIRED",
346
- "message": "The auth token has expired. Take the resource token in "
347
- "AAuth-Requirement to your PS for a fresh one.",
348
- },
349
- headers={"AAuth-Requirement": build_aauth_requirement(
350
- reason=None, resource_token=token)},
351
- )
423
+ def _signature_error(self, error: VerificationError) -> Response:
424
+ code = {"revoked_jwt": "AUTH_TOKEN_REVOKED", "expired_jwt": "AUTH_TOKEN_EXPIRED"}.get(
425
+ error.code, "SIGNATURE_INVALID")
426
+ return signature_error_response(error, code=code,
427
+ requirement=_repair_requirement(error))
428
+
429
+ def _revoked(self) -> Response:
430
+ """The meter knows the token is revoked (the verifier was not wired to
431
+ it, or learned out of band): the same -11 §11.12.5 answer."""
432
+ error = VerificationError("revoked_jwt", "the issuer has withdrawn this token",
433
+ token_typ=AAUTH_AUTH_TYP)
434
+ return self._signature_error(error)
352
435
 
353
436
  async def _refusal_with_token(
354
437
  self, *, reason: str, envelope: BudgetClaim,
regent_httpsig/sfv.py CHANGED
@@ -34,7 +34,10 @@ __all__ = [
34
34
  "StaticKeyResolver",
35
35
  "build_aauth_budget_header",
36
36
  "build_aauth_requirement",
37
+ "build_person_token_requirement",
37
38
  "parse_signature_agent",
39
+ "parse_signature_input",
40
+ "parse_signature_key",
38
41
  "parse_signature_key_header",
39
42
  ]
40
43
 
@@ -120,20 +123,69 @@ def parse_signature_agent(value: str) -> str | None:
120
123
  return None
121
124
 
122
125
 
123
- def parse_signature_key_header(value: str) -> tuple[str, str] | None:
124
- """``Signature-Key: sig=jwt;jwt="eyJ…"`` → (label, jwt) — the AAuth carrier."""
126
+ def _plain(value: Any) -> Any:
127
+ """An sf-value as a plain Python value (Token/String → str, Integer → int)."""
128
+ if isinstance(value, (int, str, bool)):
129
+ return value
130
+ try:
131
+ inner = getattr(value, "value", value)
132
+ return inner if isinstance(inner, (int, str, bool)) else str(inner)
133
+ except Exception: # noqa: BLE001
134
+ return str(value)
135
+
136
+
137
+ def parse_signature_key(value: str) -> list[tuple[str, str, dict[str, Any]]]:
138
+ """``Signature-Key`` (draft-hardt-httpbis-signature-key) → one
139
+ ``(label, scheme, params)`` per member, in header order. The member's bare
140
+ item is the scheme token (``jwt``, ``jwks_uri``, ``hwk``, …); its parameters
141
+ are returned as plain values. Malformed header → ``[]``."""
142
+ out: list[tuple[str, str, dict[str, Any]]] = []
125
143
  try:
126
144
  node = SFDictionary()
127
145
  node.parse(value.encode())
128
146
  for label, member in node.items():
129
- jwt_param = member.params.get("jwt")
130
- if jwt_param:
131
- return str(label), str(jwt_param)
147
+ scheme = _plain(getattr(member, "value", member))
148
+ params = {str(k): _plain(v) for k, v in member.params.items()}
149
+ out.append((str(label), str(scheme), params))
132
150
  except Exception: # noqa: BLE001
133
- return None
151
+ return []
152
+ return out
153
+
154
+
155
+ def parse_signature_key_header(value: str) -> tuple[str, str] | None:
156
+ """``Signature-Key: sig=jwt;jwt="eyJ…"`` → (label, jwt) — the AAuth carrier.
157
+ Kept for callers of 0.6; :func:`parse_signature_key` sees every scheme."""
158
+ for label, scheme, params in parse_signature_key(value):
159
+ if scheme == "jwt" and params.get("jwt"):
160
+ return label, str(params["jwt"])
134
161
  return None
135
162
 
136
163
 
164
+ def parse_signature_input(value: str) -> dict[str, tuple[list[str], dict[str, Any]]]:
165
+ """``Signature-Input`` (RFC 9421 §4.1) → ``{label: (covered components, params)}``.
166
+ Components come back as their identifiers (``@method``, ``content-digest``,
167
+ ``"signature-agent";key="agent2"`` → ``signature-agent``); params as plain values."""
168
+ out: dict[str, tuple[list[str], dict[str, Any]]] = {}
169
+ try:
170
+ node = SFDictionary()
171
+ node.parse(value.encode())
172
+ for label, member in node.items():
173
+ # An InnerList is a UserList of Items; a bare Item is not a valid member here.
174
+ items = list(member) if hasattr(member, "data") else []
175
+ covered = [str(_plain(getattr(item, "value", item))) for item in items]
176
+ params = {str(k): _plain(v) for k, v in member.params.items()}
177
+ out[str(label)] = (covered, params)
178
+ except Exception: # noqa: BLE001
179
+ return {}
180
+ return out
181
+
182
+
183
+ def build_person_token_requirement() -> str:
184
+ """``AAuth-Requirement: requirement=person-token`` (-11 §6.4): what a resource
185
+ answers a revoked or expired auth token with. The header takes no parameters."""
186
+ return "requirement=person-token"
187
+
188
+
137
189
  # ── AAuth Budgets response headers (draft-hardt-aauth-budgets) ───────────────
138
190
  # Hand-serialized: the value space is tiny (non-negative sf-integers, one
139
191
  # sf-string, sf-tokens) and the golden tests round-trip the output through the