tauri_typed_ipc/validate.rs
1//! Opt-in runtime payload validation (`validate` feature, default off).
2//!
3//! The typed client and the drift `check` keep the two sides in agreement at
4//! build time, but TypeScript types vanish at runtime: a malformed or
5//! version-skewed payload -- a hand-written `invoke`, a stale client, bad data
6//! from the webview -- is otherwise only caught where serde deserialization
7//! trips, deep in a dispatch, with a serde message. This feature adds a schema
8//! contract at the boundary.
9//!
10//! The contract is a single JSON Schema derived from the *same* specta graph
11//! the bindings are rendered from, so both ends check one document: the client
12//! validates its arguments before `invoke`, and [`handler_validated`] validates
13//! the incoming payload before dispatch. JSON Schema (not a TypeScript-only
14//! schema) is deliberate -- it is the only contract the Rust side can validate
15//! against too.
16//!
17//! Off by default: without the feature the crate pulls no validator and a
18//! [`handler`](crate::handler) does no per-payload schema work.
19//!
20//! ```no_run
21//! # use tauri_typed_ipc::procedures;
22//! # #[procedures]
23//! # trait Greeter { fn greet(&self, name: String) -> String; }
24//! # struct Backend;
25//! # impl Greeter for Backend { fn greet(&self, name: String) -> String { name } }
26//! # fn mount<R: tauri::Runtime>(builder: tauri::Builder<R>) -> Result<tauri::Builder<R>, Box<dyn std::error::Error>> {
27//! let validator = tauri_typed_ipc::Validator::new().register::<GreeterProcedures>()?;
28//! Ok(builder.invoke_handler(tauri_typed_ipc::handler_validated(
29//! Backend.into_procedures(),
30//! validator,
31//! )))
32//! # }
33//! ```
34
35use std::collections::HashMap;
36
37use serde_json::{Value, json};
38use specta::Types;
39use specta::datatype::{DataType, Field, NamedDataType, NamedReferenceType, Reference, Struct};
40
41use crate::{ProcedureSet, ProcedureType};
42
43/// Compiled per-command argument validators, built from one or more procedure
44/// sets. Reuses the binding descriptor a `#[procedures]` trait already
45/// generates, so validation needs no separate schema definition and cannot
46/// drift from the client's types.
47///
48/// Build one with [`new`](Self::new) and [`register`](Self::register), then
49/// pass it to [`handler_validated`](crate::handler_validated).
50#[must_use]
51pub struct Validator {
52 /// Keyed by wire command name (namespaced when the set is), so a lookup
53 /// matches exactly what the handler routes on.
54 commands: HashMap<String, jsonschema::Validator>,
55}
56
57impl Validator {
58 /// An empty validator that passes every command. Add sets with
59 /// [`register`](Self::register).
60 pub fn new() -> Self {
61 Self {
62 commands: HashMap::new(),
63 }
64 }
65
66 /// Compile validators for every command in a procedure set, from its
67 /// generated `{Trait}Procedures` descriptor.
68 ///
69 /// Errors if the JSON Schema cannot be built or compiled -- structural,
70 /// so it surfaces at startup, when the validator is assembled, not
71 /// per call.
72 pub fn register<P: ProcedureSet>(mut self) -> Result<Self, ValidatorError> {
73 // Collect the set's wire shapes -- the same call the bindings
74 // generator makes -- then compile one validator per command from the
75 // shared contract builder (the single source the TypeScript client
76 // embeds too).
77 let mut types = Types::default();
78 let procedures = P::procedures(&mut types);
79 let schemas = build_command_schemas(
80 types,
81 &[SchemaSet {
82 namespace: P::NAMESPACE,
83 procedures: &procedures,
84 }],
85 )?;
86 for (wire, schema) in schemas {
87 let compiled = jsonschema::validator_for(&schema)
88 .map_err(|source| ValidatorError::Compile(source.to_string()))?;
89 self.commands.insert(wire, compiled);
90 }
91 Ok(self)
92 }
93
94 /// Validate a call's JSON arguments against the command's contract.
95 /// `Ok(())` for a command this validator does not cover -- routing, not
96 /// validation, decides whether an unknown command is an error.
97 pub fn validate(&self, command: &str, args: &Value) -> Result<(), ValidationError> {
98 let Some(compiled) = self.commands.get(command) else {
99 return Ok(());
100 };
101 if compiled.is_valid(args) {
102 return Ok(());
103 }
104 // First failure is enough for a boundary rejection; the full set is
105 // available to callers who want it via the compiled schema.
106 let message = compiled.iter_errors(args).next().map_or_else(
107 || "does not match the schema".to_string(),
108 |err| err.to_string(),
109 );
110 Err(ValidationError {
111 command: command.to_string(),
112 message,
113 })
114 }
115}
116
117impl Default for Validator {
118 fn default() -> Self {
119 Self::new()
120 }
121}
122
123/// One procedure set's contribution to the schema contract: its wire namespace
124/// (namespaced commands become `ns.method`) and the procedure signatures the
125/// bindings generator collected.
126pub(crate) struct SchemaSet<'a> {
127 /// `#[procedures(namespace = ...)]`, or `None` for bare wire names.
128 pub namespace: Option<&'a str>,
129 /// The set's procedure descriptors, in declaration order.
130 pub procedures: &'a [ProcedureType],
131}
132
133/// Build the per-command JSON Schema contract for one or more sets: one schema
134/// per command, rooted at a synthesized args definition and carrying the shared
135/// registry `definitions` so every `$ref` resolves. This is the single source
136/// both ends use -- the Rust handler compiles these into validators, and the
137/// TypeScript client embeds the identical documents.
138///
139/// Only the arguments are modelled. A `Channel<T>`'s wire id and any
140/// forward-compatible extras ride through as permitted additional properties.
141pub(crate) fn build_command_schemas(
142 mut types: Types,
143 sets: &[SchemaSet<'_>],
144) -> Result<Vec<(String, Value)>, ValidatorError> {
145 // Synthesize one named args struct per command into the shared registry, so
146 // a single export renders every command's object schema plus the named
147 // types they reference. A global index keeps definition names unique across
148 // sets; the wire name (namespaced when the set is) is the lookup key.
149 let mut wire_names = Vec::new();
150 for set in sets {
151 for procedure in set.procedures {
152 let def_name = format!("TtipcArgs{}", wire_names.len());
153 let fields: Vec<(String, DataType)> = procedure
154 .args
155 .iter()
156 .map(|(name, ty)| ((*name).to_string(), inline_leaf(ty.clone())))
157 .collect();
158 NamedDataType::new(def_name.clone(), &mut types, |_types, ndt| {
159 let mut builder = Struct::named();
160 for (name, ty) in &fields {
161 builder = builder.field(name.clone(), Field::new(ty.clone()));
162 }
163 ndt.ty = Some(builder.build());
164 });
165 let wire = match set.namespace {
166 Some(namespace) => format!("{namespace}.{}", procedure.name),
167 None => procedure.name.to_string(),
168 };
169 wire_names.push((wire, def_name));
170 }
171 }
172
173 let document = specta_jsonschema::JsonSchema::default()
174 .export(&types, specta_serde::Format)
175 .map_err(|source| ValidatorError::Schema(source.to_string()))?;
176 let document: Value = serde_json::from_str(&document)
177 .map_err(|source| ValidatorError::Schema(source.to_string()))?;
178 // The exporter keys its named types under "definitions" (draft-07) or
179 // "$defs" (2019-09+), and stamps the matching `$schema`, depending on the
180 // draft it emits -- which varies across specta-jsonschema versions. Read
181 // whichever the document used so the per-command `$ref` resolves against
182 // the same dialect the definitions were rendered for.
183 let defs_key = if document.get("$defs").is_some() {
184 "$defs"
185 } else {
186 "definitions"
187 };
188 let definitions = document.get(defs_key).cloned().unwrap_or_else(|| json!({}));
189 let dialect = document.get("$schema").cloned();
190
191 let mut schemas = Vec::with_capacity(wire_names.len());
192 for (wire, def_name) in wire_names {
193 let mut schema = serde_json::Map::new();
194 if let Some(dialect) = dialect.clone() {
195 schema.insert("$schema".to_string(), dialect);
196 }
197 schema.insert(
198 "$ref".to_string(),
199 Value::String(format!("#/{defs_key}/{def_name}")),
200 );
201 schema.insert(defs_key.to_string(), definitions.clone());
202 schemas.push((wire, Value::Object(schema)));
203 }
204 Ok(schemas)
205}
206
207/// Unwrap inline named references to the datatype they render as, recursively,
208/// so a bare primitive argument (`name: String`) and container elements
209/// (`tags: Vec<String>`) carry a real schema rather than an empty `{}`.
210///
211/// specta returns a primitive as a named reference whose `inner` inlines the
212/// primitive; JSON Schema renders such a reference as an empty definition,
213/// which matches anything. A genuine named type (`Progress`) has a
214/// `Reference` inner and is kept as a `$ref` for the exporter to render.
215fn inline_leaf(ty: DataType) -> DataType {
216 match ty {
217 DataType::Reference(Reference::Named(named)) => {
218 if let NamedReferenceType::Inline { dt, .. } = &named.inner {
219 inline_leaf((**dt).clone())
220 } else {
221 DataType::Reference(Reference::Named(named))
222 }
223 }
224 DataType::List(mut list) => {
225 list.ty = Box::new(inline_leaf(*list.ty));
226 DataType::List(list)
227 }
228 DataType::Map(mut map) => {
229 let key = inline_leaf(map.key_ty().clone());
230 let value = inline_leaf(map.value_ty().clone());
231 *map.key_ty_mut() = key;
232 *map.value_ty_mut() = value;
233 DataType::Map(map)
234 }
235 DataType::Tuple(mut tuple) => {
236 tuple.elements = tuple.elements.into_iter().map(inline_leaf).collect();
237 DataType::Tuple(tuple)
238 }
239 DataType::Nullable(inner) => DataType::Nullable(Box::new(inline_leaf(*inner))),
240 DataType::Intersection(parts) => {
241 DataType::Intersection(parts.into_iter().map(inline_leaf).collect())
242 }
243 // Primitive, Struct, Enum, Generic, and opaque references render as-is.
244 // Unwrapping never over-constrains, so a variant left alone at most
245 // under-validates -- it never rejects a valid payload.
246 other => other,
247 }
248}
249
250/// Failure assembling a [`Validator`] from a procedure set: the contract's
251/// JSON Schema could not be built or compiled. Structural, so it is raised
252/// once at [`register`](Validator::register), never per call.
253#[derive(Debug, thiserror::Error)]
254pub enum ValidatorError {
255 /// The JSON Schema could not be rendered from the specta graph.
256 #[error("building the JSON Schema contract failed: {0}")]
257 Schema(String),
258 /// The rendered schema could not be compiled into a validator.
259 #[error("compiling the validator failed: {0}")]
260 Compile(String),
261}
262
263/// A payload that failed its command's contract. Carried into
264/// [`DispatchError::Invalid`](crate::DispatchError::Invalid) by a validating
265/// handler and rejected at the boundary, before dispatch.
266#[derive(Debug, thiserror::Error)]
267#[error("invalid payload for {command:?}: {message}")]
268pub struct ValidationError {
269 /// The wire command whose arguments failed.
270 pub command: String,
271 /// The first schema violation, as a human-readable message.
272 pub message: String,
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use crate::{ProcedureSet, ProcedureType};
279 use specta::Type;
280
281 // A hand-built procedure set, so the Validator is exercised without the
282 // `#[procedures]` macro (which cannot expand inside the defining crate).
283 // Two commands: a bare-primitive arg and a Vec-of-primitive arg -- the two
284 // cases the inline-leaf transform exists to cover.
285 struct Fixture;
286 impl ProcedureSet for Fixture {
287 const OBJECT: &'static str = "fixture";
288 fn procedures(types: &mut Types) -> Vec<ProcedureType> {
289 vec![
290 ProcedureType {
291 name: "greet",
292 args: vec![("name", <String as Type>::definition(types))],
293 channels: vec![],
294 output: <String as Type>::definition(types),
295 error: None,
296 },
297 ProcedureType {
298 name: "sum",
299 args: vec![("values", <Vec<u32> as Type>::definition(types))],
300 channels: vec![],
301 output: <u32 as Type>::definition(types),
302 error: None,
303 },
304 ]
305 }
306 }
307
308 fn validator() -> Validator {
309 Validator::new()
310 .register::<Fixture>()
311 .expect("fixture validator builds")
312 }
313
314 #[test]
315 fn accepts_well_typed_payloads() {
316 let v = validator();
317 assert!(v.validate("greet", &json!({ "name": "world" })).is_ok());
318 assert!(v.validate("sum", &json!({ "values": [1, 2, 3] })).is_ok());
319 }
320
321 #[test]
322 fn rejects_a_wrong_typed_primitive_argument() {
323 // The load-bearing case: a bare String arg is really validated, not
324 // waved through as an empty schema.
325 let v = validator();
326 let err = v
327 .validate("greet", &json!({ "name": 5 }))
328 .expect_err("a number is not a string");
329 assert_eq!(err.command, "greet");
330 }
331
332 #[test]
333 fn rejects_a_wrong_typed_container_element() {
334 // The recursive transform: Vec<u32> validates its elements, not just
335 // that the value is an array.
336 let v = validator();
337 assert!(v.validate("sum", &json!({ "values": ["x"] })).is_err());
338 }
339
340 #[test]
341 fn rejects_a_missing_required_argument() {
342 let v = validator();
343 assert!(v.validate("greet", &json!({})).is_err());
344 }
345
346 #[test]
347 fn passes_unknown_commands_through() {
348 // Not this validator's concern -- routing decides.
349 let v = validator();
350 assert!(v.validate("not_registered", &json!({})).is_ok());
351 }
352}