Skip to main content

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}