prost_protovalidate_build/lib.rs
1//! Compile-time code generator for Protocol Buffer validation.
2//!
3//! Generates `impl prost_protovalidate::Validate` for messages that have
4//! **only** standard `buf.validate` rules (no CEL expressions). Validators
5//! run through monomorphized direct field access at runtime — no
6//! `prost-reflect` transcoding, no CEL interpreter on the hot path.
7//! Messages with any CEL rules are, per builder configuration, skipped with
8//! a `cargo:warning` (validate them with the runtime
9//! `prost_protovalidate::Validator`), turned into a hard build error
10//! ([`Builder::fail_on_runtime_only`] — a rule can never be silently
11//! skipped), or, on the buffa backend, routed through an embedded runtime
12//! engine ([`Builder::runtime_bridge`]) so the whole schema — CEL included —
13//! validates through one uniform `msg.validate()`.
14//!
15//! # Backends
16//!
17//! Generated code targets [prost](https://crates.io/crates/prost) message
18//! types by default. Select [`Backend::Buffa`] via [`Builder::backend`]
19//! when the types were generated by
20//! [buffa](https://crates.io/crates/buffa) (`buffa-build`, or a wrapper
21//! such as `connectrpc-build`): presence checks go through
22//! `MessageField`, enum accesses through `EnumValue::to_i32`, and type
23//! paths keep verbatim proto names. Consumers pairing the buffa backend
24//! with `prost-protovalidate`'s `default-features = false` get build-time
25//! validation with no reflection or CEL in the runtime dependency graph.
26//!
27//! # Usage
28//!
29//! In your `build.rs`:
30//!
31//! ```rust,no_run
32//! fn main() -> Result<(), Box<dyn std::error::Error>> {
33//! // First, compile protos with prost-build (writes descriptor set)
34//! let descriptor_path = std::path::PathBuf::from(std::env::var("OUT_DIR")?)
35//! .join("file_descriptor_set.bin");
36//! prost_build::Config::new()
37//! .file_descriptor_set_path(&descriptor_path)
38//! .compile_protos(&["proto/service.proto"], &["proto/"])?;
39//!
40//! // Then generate validation impls
41//! prost_protovalidate_build::Builder::new()
42//! .file_descriptor_set_path(&descriptor_path)?
43//! .compile()?;
44//! Ok(())
45//! }
46//! ```
47//!
48//! Then include the generated code alongside the prost-generated code:
49//!
50//! ```rust,ignore
51//! include!(concat!(env!("OUT_DIR"), "/validate_impl.rs"));
52//! ```
53
54mod backend;
55mod codegen;
56mod message;
57mod naming;
58mod rules;
59
60use std::fs;
61use std::path::{Path, PathBuf};
62
63use prost_reflect::DescriptorPool;
64
65pub use backend::Backend;
66
67/// Builder for configuring and running the validation code generator.
68#[derive(Default)]
69pub struct Builder {
70 file_descriptor_set_bytes: Option<Vec<u8>>,
71 out_dir: Option<PathBuf>,
72 extern_paths: Vec<(String, String)>,
73 backend: Backend,
74 fail_on_runtime_only: bool,
75 runtime_bridge: bool,
76}
77
78impl Builder {
79 /// Create a new builder with default settings.
80 #[must_use]
81 pub fn new() -> Self {
82 Self::default()
83 }
84
85 /// Set the file descriptor set bytes directly.
86 #[must_use]
87 pub fn file_descriptor_set_bytes(mut self, bytes: Vec<u8>) -> Self {
88 self.file_descriptor_set_bytes = Some(bytes);
89 self
90 }
91
92 /// Read the file descriptor set from a file path.
93 ///
94 /// # Errors
95 ///
96 /// Returns an error if the file cannot be read.
97 pub fn file_descriptor_set_path(mut self, path: impl AsRef<Path>) -> Result<Self, Error> {
98 let bytes = fs::read(path.as_ref()).map_err(|e| Error::Io {
99 path: path.as_ref().to_path_buf(),
100 source: e,
101 })?;
102 self.file_descriptor_set_bytes = Some(bytes);
103 Ok(self)
104 }
105
106 /// Override the output directory (defaults to `OUT_DIR` env var).
107 #[must_use]
108 pub fn out_dir(mut self, path: impl Into<PathBuf>) -> Self {
109 self.out_dir = Some(path.into());
110 self
111 }
112
113 /// Map a proto package path to a Rust module path.
114 ///
115 /// This is equivalent to prost-build's `extern_path` and should match
116 /// your prost-build configuration.
117 ///
118 /// # Example
119 ///
120 /// ```rust,no_run
121 /// prost_protovalidate_build::Builder::new()
122 /// .extern_path(".my.package", "::my_crate::my_package");
123 /// ```
124 #[must_use]
125 pub fn extern_path(
126 mut self,
127 proto_path: impl Into<String>,
128 rust_path: impl Into<String>,
129 ) -> Self {
130 self.extern_paths
131 .push((proto_path.into(), rust_path.into()));
132 self
133 }
134
135 /// Select the protobuf runtime the generated impls target (defaults to
136 /// [`Backend::Prost`]).
137 ///
138 /// Use [`Backend::Buffa`] when the message types were generated by
139 /// `buffa-build` (or a wrapper such as `connectrpc-build`): presence
140 /// checks go through `MessageField`, enum accesses through
141 /// `EnumValue::to_i32`, and type paths keep verbatim proto names
142 /// (`UUID` stays `UUID` — prost would rename it to `Uuid`).
143 #[must_use]
144 pub fn backend(mut self, backend: Backend) -> Self {
145 self.backend = backend;
146 self
147 }
148
149 /// Turn "route to runtime validator" outcomes into hard build errors.
150 ///
151 /// By default, messages the generator cannot cover (CEL rules,
152 /// unsupported shapes) are skipped with a `cargo:warning`, on the
153 /// assumption that the runtime `Validator` picks them up. Consumers that
154 /// ship **no** runtime validation path (e.g. build-time-only validation
155 /// against a slim `prost-protovalidate`) must set this to `true` so a
156 /// rule can never be silently skipped.
157 #[must_use]
158 pub fn fail_on_runtime_only(mut self, fail: bool) -> Self {
159 self.fail_on_runtime_only = fail;
160 self
161 }
162
163 /// Route messages the generator cannot cover to the runtime `Validator`
164 /// instead of skipping (`cargo:warning`) or hard-failing them.
165 ///
166 /// In this mode a message with CEL rules (or a shape the generator does not
167 /// support) receives an `impl Validate` that encodes the value to protobuf
168 /// wire bytes and validates it through an embedded descriptor set — reusing
169 /// the full runtime engine, including the CEL interpreter, rather than a
170 /// second code path. This gives buffa-generated types full rule coverage
171 /// (matching the runtime `Validator`) at the cost of pulling
172 /// `prost-protovalidate`'s `reflect`/`cel` features into the consumer for
173 /// the routed messages.
174 ///
175 /// # Feature tiers
176 ///
177 /// The default build-time path (standard rules, especially with
178 /// [`fail_on_runtime_only`](Builder::fail_on_runtime_only)) stays
179 /// reflection- and CEL-free. `runtime_bridge` is the explicit opt-in that
180 /// crosses into the runtime engine: the routed messages require
181 /// `prost-protovalidate` built with `reflect` (and `cel` for CEL rules),
182 /// otherwise the generated code fails to compile.
183 ///
184 /// # `OUT_DIR` requirement
185 ///
186 /// The generated bridge embeds the descriptor set next to the generated code
187 /// and loads it via `include_bytes!(concat!(env!("OUT_DIR"), …))`, so the
188 /// generated file must be included from `OUT_DIR` (the default). Do **not**
189 /// combine `runtime_bridge` with an [`out_dir`](Builder::out_dir) override
190 /// for code that is subsequently compiled — the embedded descriptor set
191 /// would not be found.
192 ///
193 /// Only supported with [`Backend::Buffa`], and mutually exclusive with
194 /// [`Builder::fail_on_runtime_only`]; [`Builder::compile`] returns
195 /// [`Error::ConflictingOptions`] otherwise.
196 #[must_use]
197 pub fn runtime_bridge(mut self, enable: bool) -> Self {
198 self.runtime_bridge = enable;
199 self
200 }
201
202 /// Run the code generator.
203 ///
204 /// # Errors
205 ///
206 /// Returns an error if the descriptor set is missing, cannot be parsed,
207 /// or the output file cannot be written.
208 pub fn compile(self) -> Result<(), Error> {
209 if self.runtime_bridge && self.fail_on_runtime_only {
210 return Err(Error::ConflictingOptions(
211 "runtime_bridge and fail_on_runtime_only are mutually exclusive".to_string(),
212 ));
213 }
214 if self.runtime_bridge && self.backend != Backend::Buffa {
215 return Err(Error::ConflictingOptions(
216 "runtime_bridge is only supported with Backend::Buffa".to_string(),
217 ));
218 }
219
220 let fds_bytes = self
221 .file_descriptor_set_bytes
222 .ok_or(Error::MissingDescriptorSet)?;
223
224 // Decode the raw bytes directly into a prost-reflect DescriptorPool.
225 // Using prost_types::FileDescriptorSet::decode() first would strip
226 // extension data (buf.validate.field, etc.) from proto options since
227 // prost does not preserve unknown fields by default.
228 let pool = DescriptorPool::decode(fds_bytes.as_slice())
229 .map_err(|e| Error::Decode(e.to_string()))?;
230
231 let out_dir = match self.out_dir {
232 Some(dir) => dir,
233 None => PathBuf::from(std::env::var("OUT_DIR").map_err(|_| Error::MissingOutDir)?),
234 };
235
236 let naming_ctx = naming::NamingContext::new(&self.extern_paths, self.backend, &pool);
237 let generated = codegen::generate(
238 &pool,
239 &naming_ctx,
240 self.fail_on_runtime_only,
241 self.runtime_bridge,
242 )?;
243
244 let file = syn::parse2(generated.tokens).map_err(|e| Error::Codegen(e.to_string()))?;
245 let formatted = prettyplease::unparse(&file);
246
247 fs::create_dir_all(&out_dir).map_err(|e| Error::Io {
248 path: out_dir.clone(),
249 source: e,
250 })?;
251
252 let output_path = out_dir.join("validate_impl.rs");
253 fs::write(&output_path, formatted).map_err(|e| Error::Io {
254 path: output_path,
255 source: e,
256 })?;
257
258 // When any message was routed through the runtime bridge, embed the
259 // (raw) descriptor set next to the generated code so the emitted
260 // `RuntimeBridge::from_fds(include_bytes!(...))` resolves at the
261 // consumer's compile time.
262 if generated.needs_fds {
263 let fds_path = out_dir.join("prost_protovalidate_validate.fds");
264 fs::write(&fds_path, &fds_bytes).map_err(|e| Error::Io {
265 path: fds_path,
266 source: e,
267 })?;
268 }
269
270 Ok(())
271 }
272}
273
274/// Errors that can occur during code generation.
275#[derive(Debug, thiserror::Error)]
276#[non_exhaustive]
277pub enum Error {
278 /// No file descriptor set was provided.
279 #[error(
280 "no file descriptor set provided; call file_descriptor_set_bytes() or file_descriptor_set_path()"
281 )]
282 MissingDescriptorSet,
283
284 /// The `OUT_DIR` environment variable is not set.
285 #[error("OUT_DIR environment variable not set; call out_dir() or run from build.rs")]
286 MissingOutDir,
287
288 /// Failed to decode the file descriptor set.
289 #[error("failed to decode file descriptor set: {0}")]
290 Decode(String),
291
292 /// Code generation produced invalid tokens.
293 #[error("code generation error: {0}")]
294 Codegen(String),
295
296 /// I/O error reading or writing files.
297 #[error("I/O error at {path}: {source}")]
298 Io {
299 /// The path that caused the error.
300 path: PathBuf,
301 /// The underlying I/O error.
302 source: std::io::Error,
303 },
304
305 /// A constraint could not be decoded from proto extensions.
306 #[error("constraint decode error: {0}")]
307 ConstraintDecode(String),
308
309 /// A message needs the runtime `Validator` (CEL rules or an unsupported
310 /// shape) but [`Builder::fail_on_runtime_only`] was set.
311 #[error(
312 "message requires runtime validation but fail_on_runtime_only is set: {0}; \
313 either drop the offending rule or validate this message with the runtime Validator"
314 )]
315 RuntimeOnly(String),
316
317 /// Two mutually incompatible builder options were combined:
318 /// [`Builder::runtime_bridge`] with [`Builder::fail_on_runtime_only`], or
319 /// `runtime_bridge` on a non-buffa backend.
320 #[error("conflicting builder options: {0}")]
321 ConflictingOptions(String),
322}