#![cfg_attr(
not(test),
deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
)]
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::error::WorkbookToolError;
use super::ProvStamp;
pub const RENDER_URI_PREFIX: &str = "workbook://render/";
pub const WORKBOOK_XLSX_MIME: &str =
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
pub const MAX_ENCODED_URI_LEN: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DecodedRender {
pub dto: Value,
pub provenance: ProvStamp,
}
#[derive(Debug, Deserialize)]
struct RenderPayload {
dto: Value,
provenance: ProvStamp,
}
#[derive(Serialize)]
struct RenderPayloadRef<'a> {
dto: &'a Value,
provenance: &'a ProvStamp,
}
#[allow(clippy::result_large_err)]
pub fn encode(dto: &Value, provenance: &ProvStamp) -> Result<String, WorkbookToolError> {
let payload = RenderPayloadRef { dto, provenance };
let json = serde_json::to_vec(&payload).map_err(|e| {
WorkbookToolError::invalid_input(format!("could not encode render payload: {e}"))
})?;
let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json);
Ok(format!("{RENDER_URI_PREFIX}{b64}"))
}
#[allow(clippy::result_large_err)]
pub fn decode(uri: &str) -> Result<DecodedRender, WorkbookToolError> {
if uri.len() > MAX_ENCODED_URI_LEN {
return Err(WorkbookToolError::invalid_input(format!(
"workbook:// URI exceeds the {MAX_ENCODED_URI_LEN}-byte limit ({} bytes)",
uri.len()
)));
}
let body = uri.strip_prefix(RENDER_URI_PREFIX).ok_or_else(|| {
WorkbookToolError::invalid_input(
"not a workbook://render/ URI (missing scheme prefix)".to_string(),
)
})?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(body)
.map_err(|e| {
WorkbookToolError::invalid_input(format!("workbook:// URI body is not base64: {e}"))
})?;
let payload: RenderPayload = serde_json::from_slice(&bytes).map_err(|e| {
WorkbookToolError::invalid_input(format!("workbook:// URI payload is not valid: {e}"))
})?;
Ok(DecodedRender {
dto: payload.dto,
provenance: payload.provenance,
})
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use serde_json::json;
fn stamp() -> ProvStamp {
ProvStamp {
bundle_id: "tax-calc".to_string(),
version: "1.1.0".to_string(),
combined_hash: "a".repeat(64),
}
}
fn dto() -> Value {
json!({
"inputs": { "gross_income": 60000.0, "filing_status": "single" },
"overrides": {},
})
}
#[test]
fn round_trip_yields_same_dto_and_provenance() {
let uri = encode(&dto(), &stamp()).expect("encode");
assert!(uri.starts_with(RENDER_URI_PREFIX), "carries the scheme");
let decoded = decode(&uri).expect("decode");
assert_eq!(decoded.dto, dto(), "dto round-trips");
assert_eq!(decoded.provenance, stamp(), "provenance round-trips");
}
#[test]
fn encode_is_deterministic() {
let a = encode(&dto(), &stamp()).expect("encode a");
let b = encode(&dto(), &stamp()).expect("encode b");
assert_eq!(a, b, "encode is deterministic");
}
#[test]
fn oversized_uri_is_rejected_before_decode() {
let big_body = "A".repeat(MAX_ENCODED_URI_LEN + 1);
let uri = format!("{RENDER_URI_PREFIX}{big_body}");
assert!(uri.len() > MAX_ENCODED_URI_LEN);
let err = decode(&uri).expect_err("oversized rejected");
assert_eq!(err.code, "invalid_input");
assert!(
err.reason.contains("limit"),
"rejected by the size guard, not by base64: {}",
err.reason
);
}
#[test]
fn corrupted_uri_decodes_to_err_never_panics() {
let uri = encode(&dto(), &stamp()).expect("encode");
let truncated = &uri[..uri.len() - 5];
let _ = decode(truncated); let garbage = format!("{RENDER_URI_PREFIX}!!!not base64!!!");
assert!(decode(&garbage).is_err(), "garbage base64 is an Err");
let wrong_scheme = "https://example.com/evil";
assert!(decode(wrong_scheme).is_err(), "wrong scheme is an Err");
let not_json = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xff, 0xfe, 0x00]);
assert!(
decode(&format!("{RENDER_URI_PREFIX}{not_json}")).is_err(),
"valid base64 of non-JSON is an Err"
);
}
proptest! {
#[test]
fn prop_encode_decode_identity(
keys in proptest::collection::vec("[a-z_]{1,12}", 0..6),
nums in proptest::collection::vec(any::<i32>(), 0..6),
) {
let mut inputs = serde_json::Map::new();
for (k, n) in keys.iter().zip(nums.iter()) {
inputs.insert(k.clone(), json!(n));
}
let d = json!({ "inputs": inputs, "overrides": {} });
let uri = encode(&d, &stamp()).expect("encode");
let again = encode(&d, &stamp()).expect("encode again");
prop_assert_eq!(&uri, &again, "encode deterministic");
let decoded = decode(&uri).expect("decode");
prop_assert_eq!(decoded.dto, d, "dto identity");
prop_assert_eq!(decoded.provenance, stamp(), "provenance identity");
}
#[test]
fn prop_decode_total(s in ".{0,2048}") {
let _ = decode(&s);
let _ = decode(&format!("{RENDER_URI_PREFIX}{s}"));
let oversized = format!("{}{}", RENDER_URI_PREFIX, "A".repeat(MAX_ENCODED_URI_LEN + 1));
match decode(&oversized) {
Ok(_) | Err(_) => {}, }
}
}
}