Skip to main content

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}