pmcp_server_toolkit/workbook/render_uri.rs
1//! The `workbook://` render-pointer URI codec (WBSV-05, V12).
2//!
3//! `render_workbook` does NOT return the `.xlsx` bytes. It validates the inputs,
4//! then returns a `workbook://` URI that encodes the (canonical) inputs PLUS the
5//! bundle provenance stamp. The bytes are recomputed per `resources/read` by
6//! decoding the URI, re-verifying provenance, re-validating the inputs, re-running
7//! the executor, and rendering (see [`super::render_resource`]). This keeps the
8//! server STATELESS (Lambda-safe — no session, no server-side render cache, V3).
9//!
10//! # The URI as an attacker-controlled payload
11//!
12//! The pointer round-trips through the client, so the URI handed back to
13//! `resources/read` is UNTRUSTED — an attacker may forge, truncate, oversize, or
14//! cross-wire it. The codec is hardened accordingly:
15//!
16//! - **Size guard FIRST (T-92-14 / V12):** [`decode`] rejects any URI longer than
17//! [`MAX_ENCODED_URI_LEN`] BEFORE any base64 work — an oversized payload never
18//! reaches the allocator-heavy decode path (DoS mitigation).
19//! - **Total, panic-free decode (T-92-17):** every malformed / truncated / garbage
20//! input returns `Err(WorkbookToolError)`, NEVER a panic. The crate `deny(panic)`
21//! lint plus the [`prop_decode_total`](tests) proptest enforce totality over
22//! arbitrary/adversarial input.
23//!
24//! Provenance verification (decoded stamp == bundle stamp) and input re-validation
25//! happen on the READ side ([`super::render_resource`]), not here — this module is
26//! purely the codec.
27//!
28//! # Privacy note (Codex MEDIUM #10)
29//!
30//! The `workbook://` URI ENCODES the caller's inputs in its payload. A client,
31//! proxy, or gateway that logs resource URIs will therefore log the inputs.
32//! Operators handling sensitive inputs must treat the URI as sensitive. See
33//! `docs/workbook-uri-spec.md` for the published contract + privacy warning.
34
35// Compiler/clippy-enforced panic-freedom on the value path (mirrors the runtime).
36#![cfg_attr(
37 not(test),
38 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
39)]
40
41use base64::Engine;
42use pmcp_workbook_runtime::RenderMode;
43use serde::{Deserialize, Serialize};
44use serde_json::Value;
45
46use super::error::WorkbookToolError;
47use super::ProvStamp;
48
49/// The `workbook://` scheme prefix every render pointer carries.
50pub const RENDER_URI_PREFIX: &str = "workbook://render/";
51
52/// The MIME type of the rendered `.xlsx` workbook (the OOXML spreadsheet type).
53/// Advertised by `render_workbook` and carried on the `resources/read` content so
54/// the client knows the base64 payload is a downloadable spreadsheet.
55pub const WORKBOOK_XLSX_MIME: &str =
56 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
57
58/// The hard upper bound on an encoded `workbook://` URI length, in bytes.
59///
60/// [`decode`] rejects any URI longer than this BEFORE doing any base64 decode —
61/// the size guard is the first thing checked, so an oversized attacker payload
62/// never reaches the allocating decode path (T-92-14 / V12, DoS mitigation).
63///
64/// 64 KiB is generous for a tax-style input map (a handful of scalars + a small
65/// provenance triple) while bounding the per-read decode cost. It is part of the
66/// published `workbook://` contract (`docs/workbook-uri-spec.md`).
67pub const MAX_ENCODED_URI_LEN: usize = 64 * 1024;
68
69/// The decoded render payload: the canonical input DTO plus the provenance stamp
70/// that was bound into the URI at `render_workbook` time.
71///
72/// The read side ([`super::render_resource`]) VERIFIES `provenance` against the
73/// live bundle stamp and RE-VALIDATES `dto` through
74/// [`super::input::validate_input`] before re-running — neither is trusted as-is.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct DecodedRender {
77 /// The canonical wire DTO (`{ inputs, overrides }`) — the SAME shape
78 /// [`super::input::validate_input`] accepts, so it re-validates on read.
79 pub dto: Value,
80 /// The provenance stamp bound into the URI at encode time. The read side
81 /// rejects the URI if this does not equal the live bundle stamp
82 /// (cross-provenance spoofing guard, T-92-15).
83 pub provenance: ProvStamp,
84 /// The render mode bound into the URI at encode time (WBVER-02). A pre-phase
85 /// URI (no `mode` key) decodes to [`RenderMode::Filled`] (back-compat); a
86 /// present-but-malformed value is a decode `Err` (never a silent `Filled`).
87 pub mode: RenderMode,
88}
89
90/// The on-wire JSON payload (pre-base64). Kept private — callers go through
91/// [`encode`] / [`decode`] which own the scheme prefix + size guard.
92///
93/// The `provenance` triple `{ bundle_id, version, combined_hash }` is
94/// [`ProvStamp`] itself (its serde derives ARE the wire contract — Codex
95/// HIGH #3: the `combined_hash` field, NEVER a source-workbook hash).
96#[derive(Debug, Deserialize)]
97struct RenderPayload {
98 /// The canonical input DTO.
99 dto: Value,
100 /// The provenance stamp bound into the URI at encode time.
101 provenance: ProvStamp,
102 /// The render mode (WBVER-02). `#[serde(default)]` makes an ABSENT `mode` key
103 /// deserialize to [`RenderMode::default()`] == `Filled` (Pitfall 1
104 /// back-compat: pre-phase URIs have no `mode` key). A PRESENT value is decoded
105 /// by `RenderMode`'s own `Deserialize`, so a malformed string surfaces as a
106 /// decode `Err` — there is deliberately no field-level catch-all.
107 #[serde(default)]
108 mode: RenderMode,
109}
110
111/// Borrowing serialize-only twin of [`RenderPayload`] — same field names and
112/// order, so the encoded bytes are identical without cloning the DTO + stamp.
113/// `mode` is appended LAST so the existing `dto`/`provenance` byte order is
114/// unchanged (keeps `encode_is_deterministic` byte-identical).
115#[derive(Serialize)]
116struct RenderPayloadRef<'a> {
117 dto: &'a Value,
118 provenance: &'a ProvStamp,
119 mode: RenderMode,
120}
121
122/// Encode a validated input DTO + provenance stamp into a `workbook://` render
123/// pointer URI.
124///
125/// The payload `{ dto, provenance }` is serialized to canonical JSON then
126/// base64-encoded with the URL-safe, unpadded alphabet (so the result is a clean
127/// URI path segment). The bytes are NOT here — they are recomputed on
128/// `resources/read` from this URI.
129///
130/// # Errors
131///
132/// Returns [`WorkbookToolError::invalid_input`] only if the canonical DTO cannot
133/// be serialized (it always can for a [`super::input::ValidatedInput`] DTO; the
134/// fallible signature keeps the call site `?`-chained and panic-free).
135#[allow(clippy::result_large_err)]
136pub fn encode(
137 dto: &Value,
138 provenance: &ProvStamp,
139 mode: RenderMode,
140) -> Result<String, WorkbookToolError> {
141 let payload = RenderPayloadRef {
142 dto,
143 provenance,
144 mode,
145 };
146 let json = serde_json::to_vec(&payload).map_err(|e| {
147 WorkbookToolError::invalid_input(format!("could not encode render payload: {e}"))
148 })?;
149 let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
150 Ok(format!("{RENDER_URI_PREFIX}{b64}"))
151}
152
153/// Decode a `workbook://` render pointer URI back into its [`DecodedRender`]
154/// payload — TOTAL and panic-free over arbitrary/adversarial input.
155///
156/// The size guard is checked FIRST (T-92-14 / V12): a URI longer than
157/// [`MAX_ENCODED_URI_LEN`] is rejected BEFORE any base64 decode, so an oversized
158/// attacker payload never reaches the allocating decode path.
159///
160/// # Errors
161///
162/// Returns [`WorkbookToolError::invalid_input`] for ANY malformed input — an
163/// oversized URI, a wrong/absent scheme prefix, non-base64 body, non-UTF-8 or
164/// non-JSON decoded bytes, or a payload missing the `dto`/`provenance` fields.
165/// NEVER panics (T-92-17, `deny(panic)` + proptest-proven).
166#[allow(clippy::result_large_err)]
167pub fn decode(uri: &str) -> Result<DecodedRender, WorkbookToolError> {
168 // 1. SIZE GUARD FIRST (T-92-14 / V12) — reject oversized BEFORE any decode.
169 if uri.len() > MAX_ENCODED_URI_LEN {
170 return Err(WorkbookToolError::invalid_input(format!(
171 "workbook:// URI exceeds the {MAX_ENCODED_URI_LEN}-byte limit ({} bytes)",
172 uri.len()
173 )));
174 }
175 // 2. Scheme prefix (a non-workbook URI is not ours).
176 let body = uri.strip_prefix(RENDER_URI_PREFIX).ok_or_else(|| {
177 WorkbookToolError::invalid_input(
178 "not a workbook://render/ URI (missing scheme prefix)".to_string(),
179 )
180 })?;
181 // 3. base64 (URL-safe, unpadded) — total: a garbage body is an Err.
182 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
183 .decode(body)
184 .map_err(|e| {
185 WorkbookToolError::invalid_input(format!("workbook:// URI body is not base64: {e}"))
186 })?;
187 // 4. JSON parse — total: non-UTF-8 / non-JSON / wrong-shape is an Err.
188 let payload: RenderPayload = serde_json::from_slice(&bytes).map_err(|e| {
189 WorkbookToolError::invalid_input(format!("workbook:// URI payload is not valid: {e}"))
190 })?;
191 Ok(DecodedRender {
192 dto: payload.dto,
193 provenance: payload.provenance,
194 mode: payload.mode,
195 })
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use proptest::prelude::*;
202 use serde_json::json;
203
204 fn stamp() -> ProvStamp {
205 ProvStamp {
206 bundle_id: "tax-calc".to_string(),
207 version: "1.1.0".to_string(),
208 combined_hash: "a".repeat(64),
209 }
210 }
211
212 fn dto() -> Value {
213 json!({
214 "inputs": { "gross_income": 60000.0, "filing_status": "single" },
215 "overrides": {},
216 })
217 }
218
219 #[test]
220 fn round_trip_yields_same_dto_and_provenance() {
221 let uri = encode(&dto(), &stamp(), RenderMode::Filled).expect("encode");
222 assert!(uri.starts_with(RENDER_URI_PREFIX), "carries the scheme");
223 let decoded = decode(&uri).expect("decode");
224 assert_eq!(decoded.dto, dto(), "dto round-trips");
225 assert_eq!(decoded.provenance, stamp(), "provenance round-trips");
226 assert_eq!(decoded.mode, RenderMode::Filled, "mode round-trips");
227 }
228
229 #[test]
230 fn round_trip_carries_inputs_only_mode() {
231 // WBVER-02: the chosen mode rides inside the payload and round-trips.
232 let uri = encode(&dto(), &stamp(), RenderMode::InputsOnly).expect("encode");
233 let decoded = decode(&uri).expect("decode");
234 assert_eq!(
235 decoded.mode,
236 RenderMode::InputsOnly,
237 "inputs_only mode round-trips through the URI"
238 );
239 assert!(
240 uri.len() < MAX_ENCODED_URI_LEN,
241 "a mode-carrying URI stays under the 64 KiB cap"
242 );
243 }
244
245 #[test]
246 fn prephase_payload_without_mode_key_decodes_to_filled() {
247 // BACK-COMPAT (LOW): a LITERAL pre-phase payload string minted WITHOUT a
248 // `mode` key (NOT a freshly-serialized struct that merely omits the field)
249 // must still decode, defaulting to Filled. This proves `#[serde(default)]`
250 // on the DECODE struct keeps old URIs valid.
251 let literal_payload = r#"{"dto":{"inputs":{"gross_income":60000.0},"overrides":{}},"provenance":{"bundle_id":"tax-calc","version":"1.1.0","combined_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}"#;
252 let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(literal_payload);
253 let uri = format!("{RENDER_URI_PREFIX}{b64}");
254 let decoded = decode(&uri).expect("a pre-phase (no-mode) payload still decodes");
255 assert_eq!(
256 decoded.mode,
257 RenderMode::Filled,
258 "an ABSENT mode key defaults to Filled (back-compat)"
259 );
260 assert_eq!(decoded.provenance, stamp(), "the rest still decodes");
261 }
262
263 #[test]
264 fn payload_with_malformed_mode_value_is_a_decode_err() {
265 // MEDIUM #3: a payload carrying a PRESENT-but-malformed `mode` value
266 // (e.g. a forged/old URI with "mode":"bogus") is a serde DECODE ERROR —
267 // decode returns Err, never a silent Filled, never a panic.
268 let bad_payload = r#"{"dto":{"inputs":{},"overrides":{}},"provenance":{"bundle_id":"tax-calc","version":"1.1.0","combined_hash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"mode":"bogus"}"#;
269 let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bad_payload);
270 let uri = format!("{RENDER_URI_PREFIX}{b64}");
271 let err = decode(&uri).expect_err("a malformed mode value is a decode Err");
272 assert_eq!(err.code, "invalid_input");
273 }
274
275 #[test]
276 fn encode_is_deterministic() {
277 // The same (dto, provenance) always encodes to the SAME URI — required for
278 // stateless regen-on-read byte-identity downstream.
279 let a = encode(&dto(), &stamp(), RenderMode::Filled).expect("encode a");
280 let b = encode(&dto(), &stamp(), RenderMode::Filled).expect("encode b");
281 assert_eq!(a, b, "encode is deterministic");
282 }
283
284 #[test]
285 fn oversized_uri_is_rejected_before_decode() {
286 // A URI longer than MAX_ENCODED_URI_LEN is rejected by the size guard
287 // FIRST, before any base64 work (T-92-14 / V12). Build a body that is
288 // valid base64 so the ONLY thing that can reject it is the size guard.
289 let big_body = "A".repeat(MAX_ENCODED_URI_LEN + 1);
290 let uri = format!("{RENDER_URI_PREFIX}{big_body}");
291 assert!(uri.len() > MAX_ENCODED_URI_LEN);
292 let err = decode(&uri).expect_err("oversized rejected");
293 assert_eq!(err.code, "invalid_input");
294 assert!(
295 err.reason.contains("limit"),
296 "rejected by the size guard, not by base64: {}",
297 err.reason
298 );
299 }
300
301 #[test]
302 fn corrupted_uri_decodes_to_err_never_panics() {
303 // A truncated / garbage body is an Err, never a panic.
304 let uri = encode(&dto(), &stamp(), RenderMode::Filled).expect("encode");
305 let truncated = &uri[..uri.len() - 5];
306 let _ = decode(truncated); // may be Ok-shaped-but-Err or Err; must not panic
307 let garbage = format!("{RENDER_URI_PREFIX}!!!not base64!!!");
308 assert!(decode(&garbage).is_err(), "garbage base64 is an Err");
309 let wrong_scheme = "https://example.com/evil";
310 assert!(decode(wrong_scheme).is_err(), "wrong scheme is an Err");
311 // valid base64 of non-JSON bytes
312 let not_json = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xff, 0xfe, 0x00]);
313 assert!(
314 decode(&format!("{RENDER_URI_PREFIX}{not_json}")).is_err(),
315 "valid base64 of non-JSON is an Err"
316 );
317 }
318
319 proptest! {
320 /// Round-trip + determinism over arbitrary valid input maps: any
321 /// string-keyed scalar input map encodes then decodes to the SAME dto +
322 /// provenance + MODE (WBVER-02), encode is deterministic, and the encoded
323 /// URI stays under MAX_ENCODED_URI_LEN.
324 #[test]
325 fn prop_encode_decode_identity(
326 keys in proptest::collection::vec("[a-z_]{1,12}", 0..6),
327 nums in proptest::collection::vec(any::<i32>(), 0..6),
328 inputs_only in any::<bool>(),
329 ) {
330 let mode = if inputs_only { RenderMode::InputsOnly } else { RenderMode::Filled };
331 let mut inputs = serde_json::Map::new();
332 for (k, n) in keys.iter().zip(nums.iter()) {
333 inputs.insert(k.clone(), json!(n));
334 }
335 let d = json!({ "inputs": inputs, "overrides": {} });
336 let uri = encode(&d, &stamp(), mode).expect("encode");
337 let again = encode(&d, &stamp(), mode).expect("encode again");
338 prop_assert_eq!(&uri, &again, "encode deterministic");
339 prop_assert!(uri.len() < MAX_ENCODED_URI_LEN, "encoded URI under the 64 KiB cap");
340 let decoded = decode(&uri).expect("decode");
341 prop_assert_eq!(decoded.dto, d, "dto identity");
342 prop_assert_eq!(decoded.provenance, stamp(), "provenance identity");
343 prop_assert_eq!(decoded.mode, mode, "mode identity");
344 }
345
346 /// Decode totality (the CLAUDE.md ALWAYS-fuzz requirement, via proptest):
347 /// `decode` over ARBITRARY/adversarial strings — random text, truncated and
348 /// garbage base64, oversized payloads past MAX_ENCODED_URI_LEN, prefixed and
349 /// unprefixed — is TOTAL: it NEVER panics and ALWAYS returns Ok or
350 /// Err(WorkbookToolError) (T-92-17). The assertion is reaching this line
351 /// without unwinding; we additionally exercise oversized + prefixed shapes.
352 #[test]
353 fn prop_decode_total(s in ".{0,2048}") {
354 // bare arbitrary string
355 let _ = decode(&s);
356 // with our scheme prefix (drives the base64/JSON arms)
357 let _ = decode(&format!("{RENDER_URI_PREFIX}{s}"));
358 // an oversized variant (drives the size guard arm)
359 let oversized = format!("{}{}", RENDER_URI_PREFIX, "A".repeat(MAX_ENCODED_URI_LEN + 1));
360 match decode(&oversized) {
361 Ok(_) | Err(_) => {}, // total: Ok|Err, never a panic
362 }
363 }
364 }
365}