Skip to main content

pmcp_server_toolkit/workbook/
render_resource.rs

1//! The stateless regen-on-read `workbook://` resource handler (WBSV-05, V3/V12).
2//!
3//! [`RenderWorkbookResource`] implements [`pmcp::server::ResourceHandler`] over a
4//! verified [`WorkbookBundle`]. `render_workbook` (the tool) hands the client a
5//! `workbook://` POINTER; the client reads that pointer via `resources/read`, and
6//! THIS handler regenerates the `.xlsx` from the URI on EVERY read — there is NO
7//! server-side session or render cache (V3, Lambda-safe). Because the URI is
8//! attacker-controlled (it round-trips through the client), every read runs the
9//! full hardening pipeline before it renders a single byte:
10//!
11//! 1. **Decode** ([`render_uri::decode`]) — the size guard (T-92-14) is the first
12//!    thing checked, so an oversized URI is rejected before any base64 work; the
13//!    decode is total and panic-free (T-92-17).
14//! 2. **Verify provenance** — the decoded provenance MUST equal the live bundle
15//!    stamp (`combined_hash`, Codex HIGH #3). A cross-provenance / forged URI is
16//!    rejected BEFORE rendering (spoofing guard, T-92-15).
17//! 3. **Re-validate inputs** — the decoded inputs are run through
18//!    [`super::input::validate_input`] AGAIN (the inputs rode through an untrusted
19//!    round-trip; an out-of-range / injected input is rejected here, T-92-16).
20//! 4. **Re-run + render** — re-run the executor over the validated seeds, then
21//!    [`pmcp_workbook_runtime::render::render_xlsx`] (writer-only, reader-free).
22//! 5. **base64 (STANDARD)** the bytes into a [`ReadResourceResult`].
23//!
24//! `render_xlsx` pins document properties to a fixed datetime, so reading the
25//! SAME URI twice yields BYTE-IDENTICAL bytes (stateless determinism).
26//!
27//! There is exactly ONE resource on this handler (no dispatching wrapper — A3).
28
29// Compiler/clippy-enforced panic-freedom on the value path (mirrors the runtime).
30#![cfg_attr(
31    not(test),
32    deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
33)]
34
35use std::sync::Arc;
36
37use async_trait::async_trait;
38use base64::Engine;
39use pmcp::types::{Content, ListResourcesResult, ReadResourceResult, ResourceInfo};
40use pmcp::ResourceHandler;
41
42use pmcp_workbook_runtime::render::render_xlsx;
43use pmcp_workbook_runtime::RenderMode;
44
45use super::input::validate_input;
46use super::render_uri::{self, WORKBOOK_XLSX_MIME};
47use super::WorkbookBundle;
48
49/// The single resource URI advertised by `resources/list` for the render surface.
50///
51/// It is the SCHEME root (no encoded payload) — a stable, listable handle, the
52/// same canonical prefix the codec mints URIs under. The concrete
53/// `workbook://render/<payload>` URIs are minted per call by `render_workbook`
54/// and read back through [`RenderWorkbookResource::read`].
55pub const RENDER_RESOURCE_LIST_URI: &str = render_uri::RENDER_URI_PREFIX;
56
57/// The stateless regen-on-read resource handler for `workbook://` render
58/// pointers (WBSV-05). Holds the shared verified bundle; every read regenerates
59/// the `.xlsx` from the (untrusted) URI — provenance-verified, re-validated,
60/// re-run, rendered, base64-encoded.
61pub struct RenderWorkbookResource {
62    bundle: Arc<WorkbookBundle>,
63}
64
65impl RenderWorkbookResource {
66    /// Build over the shared verified bundle.
67    #[must_use]
68    pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
69        Self { bundle }
70    }
71
72    /// The `ResourceInfo` entry advertised by `resources/list`.
73    fn list_entry(&self) -> ResourceInfo {
74        ResourceInfo::new(RENDER_RESOURCE_LIST_URI, "Rendered workbook (.xlsx)")
75            .with_description(
76                "Download the computed workbook as an .xlsx. Read a workbook://render/<...> \
77                 URI minted by the render_workbook tool; the spreadsheet is regenerated \
78                 statelessly from the URI on each read.",
79            )
80            .with_mime_type(WORKBOOK_XLSX_MIME)
81    }
82
83    /// The stateless regen pipeline as a `Result` so the caller maps a domain
84    /// failure to a protocol error ONCE, at the boundary. Decomposed out of the
85    /// trait `read` to keep each fn under cognitive complexity 25.
86    fn regenerate(&self, uri: &str) -> Result<String, RegenError> {
87        // 1. Decode (size guard + total decode are inside render_uri::decode).
88        let decoded = render_uri::decode(uri).map_err(|e| RegenError::BadUri(e.reason))?;
89        // 2. Verify provenance == the live bundle stamp (cross-provenance guard).
90        //    Field-wise against the lock — no allocation per read.
91        let lock = &self.bundle.stamp;
92        if decoded.provenance.bundle_id != lock.bundle_id
93            || decoded.provenance.version != lock.version
94            || decoded.provenance.combined_hash != lock.combined
95        {
96            return Err(RegenError::CrossProvenance);
97        }
98        // WBVER-02: capture the render mode (Copy) before `dto` is moved into
99        // validate_input. `mode` is a RENDER parameter, not a manifest input, so it
100        // is NOT re-validated against the manifest.
101        let mode = decoded.mode;
102        // 3. RE-VALIDATE the decoded inputs (injected/out-of-range guard).
103        let validated = validate_input(decoded.dto, &self.bundle.manifest, &self.bundle.cell_map)
104            .map_err(|e| RegenError::Invalid(e.reason))?;
105        // 4. Re-run + render (writer-only, reader-free) in the URI's chosen mode.
106        let run = super::handler::run_bundle(&self.bundle, validated.seeds)
107            .map_err(|e| RegenError::Invalid(e.reason))?;
108        let bytes = render_xlsx(&self.bundle.layout, &run, mode)
109            .map_err(|e| RegenError::Render(e.to_string()))?;
110        // 5. base64 STANDARD the bytes (the xlsx payload — STANDARD, not the
111        //    URL-safe alphabet the URI itself uses).
112        Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
113    }
114}
115
116/// The internal regen failure classes, mapped to a protocol error at the
117/// boundary. A `workbook://` read failure is an infrastructure/protocol error
118/// (the client handed us a bad resource URI) — distinct from a tool DOMAIN
119/// failure (which rides `isError:true` in `structuredContent`).
120#[derive(Debug)]
121enum RegenError {
122    /// The URI was oversized / malformed / not a workbook:// URI.
123    BadUri(String),
124    /// The decoded provenance did not match the live bundle (spoofing).
125    CrossProvenance,
126    /// The decoded inputs failed re-validation (injection / out-of-range).
127    Invalid(String),
128    /// The xlsx render failed.
129    Render(String),
130}
131
132impl RegenError {
133    /// Map to a `pmcp` protocol error (mirrors `resources.rs` `read` errors).
134    fn into_protocol(self) -> pmcp::Error {
135        match self {
136            RegenError::BadUri(r) => pmcp::Error::protocol(
137                pmcp::ErrorCode::INVALID_PARAMS,
138                format!("invalid workbook:// resource URI: {r}"),
139            ),
140            RegenError::CrossProvenance => pmcp::Error::protocol(
141                pmcp::ErrorCode::INVALID_PARAMS,
142                "workbook:// URI provenance does not match the served bundle".to_string(),
143            ),
144            RegenError::Invalid(r) => pmcp::Error::protocol(
145                pmcp::ErrorCode::INVALID_PARAMS,
146                format!("workbook:// URI inputs failed re-validation: {r}"),
147            ),
148            RegenError::Render(r) => pmcp::Error::protocol(
149                pmcp::ErrorCode::INTERNAL_ERROR,
150                format!("workbook render failed: {r}"),
151            ),
152        }
153    }
154}
155
156#[async_trait]
157impl ResourceHandler for RenderWorkbookResource {
158    async fn list(
159        &self,
160        _cursor: Option<String>,
161        _extra: pmcp::RequestHandlerExtra,
162    ) -> pmcp::Result<ListResourcesResult> {
163        Ok(ListResourcesResult::new(vec![self.list_entry()]))
164    }
165
166    async fn read(
167        &self,
168        uri: &str,
169        _extra: pmcp::RequestHandlerExtra,
170    ) -> pmcp::Result<ReadResourceResult> {
171        let b64 = self.regenerate(uri).map_err(RegenError::into_protocol)?;
172        // MIME-typed-wire: the base64 .xlsx rides as resource content carrying the
173        // OOXML spreadsheet MIME type so the client can decode + download it.
174        Ok(ReadResourceResult::new(vec![Content::resource_with_text(
175            uri.to_string(),
176            b64,
177            WORKBOOK_XLSX_MIME,
178        )]))
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::super::ProvStamp;
185    use super::*;
186    use std::path::{Path, PathBuf};
187
188    use pmcp_workbook_runtime::{load_bundle, LocalDirSource};
189    use serde_json::json;
190
191    fn golden_dir() -> PathBuf {
192        Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tax-calc@1.1.0")
193    }
194
195    fn golden_bundle() -> Arc<WorkbookBundle> {
196        let source = LocalDirSource::new(golden_dir());
197        Arc::new(load_bundle(&source).expect("golden bundle boots"))
198    }
199
200    /// Mint a valid workbook:// URI for the golden bundle from the given inputs.
201    fn valid_uri(bundle: &Arc<WorkbookBundle>, inputs: serde_json::Value) -> String {
202        let validated = validate_input(inputs, &bundle.manifest, &bundle.cell_map)
203            .expect("inputs validate for fixture");
204        render_uri::encode(
205            &validated.canonical_dto,
206            &ProvStamp::from_bundle(bundle),
207            RenderMode::Filled,
208        )
209        .expect("encode fixture uri")
210    }
211
212    #[test]
213    fn read_returns_base64_xlsx_and_is_byte_identical_across_reads() {
214        let bundle = golden_bundle();
215        let res = RenderWorkbookResource::new(bundle.clone());
216        let uri = valid_uri(
217            &bundle,
218            json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }),
219        );
220
221        let first = res.regenerate(&uri).expect("first read renders");
222        let second = res.regenerate(&uri).expect("second read renders");
223        // base64 decodes to real bytes.
224        let bytes = base64::engine::general_purpose::STANDARD
225            .decode(&first)
226            .expect("valid base64 xlsx");
227        // .xlsx is a ZIP container — starts with the PK signature.
228        assert_eq!(
229            &bytes[..2],
230            b"PK",
231            "rendered payload is an xlsx (ZIP) container"
232        );
233        // Stateless determinism: reading the SAME URI twice is byte-identical.
234        assert_eq!(first, second, "regen-on-read is byte-identical (stateless)");
235    }
236
237    #[test]
238    fn cross_provenance_uri_errors_before_rendering() {
239        let bundle = golden_bundle();
240        let res = RenderWorkbookResource::new(bundle.clone());
241        // Encode a URI bound to a DIFFERENT (forged) provenance stamp.
242        let forged = ProvStamp {
243            bundle_id: "tax-calc".to_string(),
244            version: "1.1.0".to_string(),
245            combined_hash: "f".repeat(64), // != the real combined_hash
246        };
247        let dto = json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" }, "overrides": {} });
248        let uri = render_uri::encode(&dto, &forged, RenderMode::Filled).expect("encode forged uri");
249
250        let err = res.regenerate(&uri).expect_err("cross-provenance rejected");
251        assert!(
252            matches!(err, RegenError::CrossProvenance),
253            "rejected as cross-provenance BEFORE rendering, got {err:?}"
254        );
255    }
256
257    #[test]
258    fn out_of_range_decoded_input_errors_via_revalidation_not_render() {
259        let bundle = golden_bundle();
260        let res = RenderWorkbookResource::new(bundle.clone());
261        // Hand-encode a URI carrying an OUT-OF-ENUM filing_status with the REAL
262        // provenance (so it passes the provenance gate but must fail re-validation).
263        let dto = json!({ "inputs": { "filing_status": "alien" }, "overrides": {} });
264        let uri = render_uri::encode(&dto, &ProvStamp::from_bundle(&bundle), RenderMode::Filled)
265            .expect("encode out-of-range uri");
266
267        let err = res.regenerate(&uri).expect_err("out-of-range rejected");
268        assert!(
269            matches!(err, RegenError::Invalid(_)),
270            "rejected by re-validation (injection guard), not rendered: {err:?}"
271        );
272    }
273
274    #[test]
275    fn oversized_uri_errors_as_bad_uri() {
276        let bundle = golden_bundle();
277        let res = RenderWorkbookResource::new(bundle);
278        let oversized = format!(
279            "{}{}",
280            render_uri::RENDER_URI_PREFIX,
281            "A".repeat(render_uri::MAX_ENCODED_URI_LEN + 1)
282        );
283        let err = res.regenerate(&oversized).expect_err("oversized rejected");
284        assert!(matches!(err, RegenError::BadUri(_)), "size-guard rejection");
285    }
286
287    #[tokio::test]
288    async fn list_returns_the_single_workbook_resource_entry() {
289        let res = RenderWorkbookResource::new(golden_bundle());
290        let extra = pmcp::RequestHandlerExtra::default();
291        let listed = res.list(None, extra).await.expect("list");
292        assert_eq!(listed.resources.len(), 1, "exactly one resource (A3)");
293        assert_eq!(listed.resources[0].uri, RENDER_RESOURCE_LIST_URI);
294        assert_eq!(
295            listed.resources[0].mime_type.as_deref(),
296            Some(WORKBOOK_XLSX_MIME)
297        );
298    }
299
300    #[tokio::test]
301    async fn read_via_trait_returns_resource_content_with_xlsx_mime() {
302        let bundle = golden_bundle();
303        let res = RenderWorkbookResource::new(bundle.clone());
304        let uri = valid_uri(
305            &bundle,
306            json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }),
307        );
308        let extra = pmcp::RequestHandlerExtra::default();
309        let result = res.read(&uri, extra).await.expect("read renders");
310        assert_eq!(result.contents.len(), 1);
311    }
312}