corpus_ipc/compatibility.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Fail-closed wire-schema compatibility for [`IpcMessage`].
4//!
5//! Crate semver (`CARGO_PKG_VERSION`) answers whether a consumer may depend on
6//! this library. It does **not** answer whether an on-wire payload may be used.
7//! [`WireCompatibility`] is the single public source of truth for that window:
8//! incoming envelopes are classified as supported, too old, or too new, and
9//! decoded only after the version check succeeds.
10//!
11//! # Compatibility table (wire version 1)
12//!
13//! | Incoming | Relation | Result |
14//! | --- | --- | --- |
15//! | `MIN_SUPPORTED - 1` (`0`) | too old | [`CompatibilityError::TooOld`] |
16//! | `MIN_SUPPORTED` (`1`) | minimum supported | [`Compatibility::Supported`] |
17//! | `CURRENT` (`1`) | current | [`Compatibility::Supported`] |
18//! | `CURRENT + 1` (`2`) | too new | [`CompatibilityError::TooNew`] |
19//!
20//! Unversioned JSON (the pre-envelope `IpcMessage` encoding) is treated as
21//! [`WireCompatibility::LEGACY_UNVERSIONED`] (`1`), which is inside the
22//! supported window.
23//!
24//! # Current encoding rules
25//!
26//! The current encoding is serde JSON with externally tagged [`IpcMessage`]
27//! variants (`{"Spikes":{...}}`, `"Ping"`, …). Unit variants encode as JSON
28//! strings; struct variants encode as single-key objects.
29//!
30//! - **Unknown fields** are ignored on structs and on the envelope object.
31//! Older readers can therefore skip additive optional fields without a
32//! wire-version bump. This is the forward-compatibility rule.
33//! - **Unknown variants** never become a valid default. [`IpcMessage`] has no
34//! `Default` impl and no `#[serde(other)]` / `#[serde(other = "...")]`
35//! catch-all; an unrecognized variant name fails to deserialize.
36//!
37//! # Non-goals
38//!
39//! This module is not a negotiation daemon, schema registry, or transport
40//! handshake. It does not promise to decode arbitrary future versions.
41
42use serde::de::{DeserializeOwned, Error as DeError};
43use serde::{Deserialize, Deserializer, Serialize};
44use serde_json::Value;
45use serde_json::value::RawValue;
46
47use crate::IpcMessage;
48use crate::validation::{Validate, ValidationError};
49
50/// Canonical wire-schema compatibility window for this crate.
51///
52/// These constants are independent of crate semver. Bump them only according
53/// to the rules in `CHANGELOG.md` (when a wire-version bump is required).
54pub struct WireCompatibility;
55
56impl WireCompatibility {
57 /// Schema version produced by this crate's envelope encoders.
58 pub const CURRENT: u32 = 1;
59
60 /// Oldest schema version this crate still accepts.
61 pub const MIN_SUPPORTED: u32 = 1;
62
63 /// Version assigned to pre-envelope `IpcMessage` JSON (no `wire_version`).
64 ///
65 /// That encoding is the original serde-tagged enum. It remains supported
66 /// for as long as `LEGACY_UNVERSIONED` stays inside
67 /// `[MIN_SUPPORTED, CURRENT]`.
68 pub const LEGACY_UNVERSIONED: u32 = 1;
69
70 /// Classify `version` against [`MIN_SUPPORTED`](Self::MIN_SUPPORTED) and
71 /// [`CURRENT`](Self::CURRENT) without decoding a payload.
72 #[must_use]
73 pub fn classify(version: u32) -> Compatibility {
74 if version < Self::MIN_SUPPORTED {
75 Compatibility::TooOld
76 } else if version > Self::CURRENT {
77 Compatibility::TooNew
78 } else {
79 Compatibility::Supported
80 }
81 }
82
83 /// Accept `version` or return a typed, actionable error.
84 ///
85 /// Call this **before** interpreting payload bytes.
86 pub fn accept(version: u32) -> Result<SupportedWireVersion, CompatibilityError> {
87 match Self::classify(version) {
88 Compatibility::Supported => Ok(SupportedWireVersion(version)),
89 Compatibility::TooOld => Err(CompatibilityError::TooOld {
90 found: version,
91 min: Self::MIN_SUPPORTED,
92 }),
93 Compatibility::TooNew => Err(CompatibilityError::TooNew {
94 found: version,
95 current: Self::CURRENT,
96 }),
97 }
98 }
99}
100
101const _: () = {
102 assert!(WireCompatibility::MIN_SUPPORTED > 0);
103 assert!(WireCompatibility::CURRENT >= WireCompatibility::MIN_SUPPORTED);
104 assert!(
105 WireCompatibility::LEGACY_UNVERSIONED >= WireCompatibility::MIN_SUPPORTED
106 && WireCompatibility::LEGACY_UNVERSIONED <= WireCompatibility::CURRENT
107 );
108};
109
110/// Result of comparing an incoming version to the supported window.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum Compatibility {
113 /// `MIN_SUPPORTED <= version <= CURRENT`.
114 Supported,
115 /// `version < MIN_SUPPORTED`.
116 TooOld,
117 /// `version > CURRENT`.
118 TooNew,
119}
120
121/// An incoming version that passed [`WireCompatibility::accept`].
122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
123pub struct SupportedWireVersion(u32);
124
125impl SupportedWireVersion {
126 /// The accepted wire version.
127 #[must_use]
128 pub const fn get(self) -> u32 {
129 self.0
130 }
131}
132
133/// Classify `version` as supported, too old, or too new.
134///
135/// This is the public function form of [`WireCompatibility::classify`].
136#[must_use]
137pub fn classify_wire_version(version: u32) -> Compatibility {
138 WireCompatibility::classify(version)
139}
140
141/// Accept `version` or return [`CompatibilityError`] before payload use.
142pub fn accept_wire_version(version: u32) -> Result<SupportedWireVersion, CompatibilityError> {
143 WireCompatibility::accept(version)
144}
145
146/// Fail-closed errors for versions outside the supported window.
147#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
148pub enum CompatibilityError {
149 /// Producer is older than this crate still decodes.
150 #[error(
151 "wire version {found} is too old (minimum supported is {min}); upgrade the producer to emit version {min} or newer"
152 )]
153 TooOld {
154 /// Version found on the envelope.
155 found: u32,
156 /// [`WireCompatibility::MIN_SUPPORTED`].
157 min: u32,
158 },
159 /// Producer is newer than this crate understands.
160 #[error(
161 "wire version {found} is too new (current is {current}); upgrade corpus-ipc or have the producer emit version {current} or older"
162 )]
163 TooNew {
164 /// Version found on the envelope.
165 found: u32,
166 /// [`WireCompatibility::CURRENT`].
167 current: u32,
168 },
169}
170
171/// Errors from envelope JSON decode, including compatibility failures.
172#[derive(Debug, thiserror::Error)]
173pub enum EnvelopeError {
174 /// Version is outside the supported window.
175 #[error(transparent)]
176 Compatibility(#[from] CompatibilityError),
177 /// Top-level JSON was not an object.
178 #[error("compatibility envelope must be a JSON object")]
179 NotAnObject,
180 /// Envelope object lacked `wire_version`.
181 #[error("missing wire_version on compatibility envelope")]
182 MissingVersion,
183 /// `wire_version` was present but not a `u32`.
184 #[error("wire_version must be a non-negative integer fitting u32, got {0}")]
185 InvalidVersion(String),
186 /// Envelope object lacked `payload` after the version check passed.
187 #[error("missing payload on compatibility envelope")]
188 MissingPayload,
189 /// Failed to parse or serialize envelope JSON.
190 #[error("envelope JSON error: {0}")]
191 Json(serde_json::Error),
192 /// Version was supported, but the payload did not decode as `T`.
193 #[error("failed to decode envelope payload: {0}")]
194 Payload(serde_json::Error),
195}
196
197/// Versioned wrapper around a hybrid-flow payload.
198///
199/// JSON shape:
200///
201/// ```json
202/// {"wire_version":1,"payload":"Ping"}
203/// ```
204///
205/// Transport ownership is unchanged: backends still send and receive bytes.
206/// Call [`WireEnvelope::decode_json`] or [`decode_ipc_message_json`] at the
207/// decode entry point so too-old / too-new envelopes fail before the payload
208/// is used.
209#[derive(Debug, Clone, PartialEq, Serialize)]
210pub struct WireEnvelope<T> {
211 /// Schema version of `payload`.
212 pub wire_version: u32,
213 /// Typed hybrid-flow message (typically [`IpcMessage`]).
214 pub payload: T,
215}
216
217impl<T> WireEnvelope<T> {
218 /// Wrap `payload` at [`WireCompatibility::CURRENT`].
219 #[must_use]
220 pub fn new(payload: T) -> Self {
221 Self {
222 wire_version: WireCompatibility::CURRENT,
223 payload,
224 }
225 }
226
227 /// Unwrap the payload after a successful decode.
228 #[must_use]
229 pub fn into_payload(self) -> T {
230 self.payload
231 }
232}
233
234impl<T: DeserializeOwned> WireEnvelope<T> {
235 /// Decode JSON bytes, checking `wire_version` before converting `payload`.
236 ///
237 /// The payload is kept as raw JSON until the version is accepted, so a
238 /// too-new/too-old envelope is rejected without building `T`.
239 pub fn decode_json(bytes: &[u8]) -> Result<Self, EnvelopeError> {
240 envelope_from_raw_parts(parse_raw_envelope(bytes)?)
241 }
242
243 /// Decode an already-parsed JSON value, checking version before `T`.
244 ///
245 /// Prefer [`Self::decode_json`] for byte input so the payload is not first
246 /// materialized as a [`Value`] tree. This entry point exists for callers
247 /// that already hold a `Value`.
248 pub fn from_json_value(value: Value) -> Result<Self, EnvelopeError> {
249 let Value::Object(mut obj) = value else {
250 return Err(EnvelopeError::NotAnObject);
251 };
252 let version_val = obj
253 .remove("wire_version")
254 .ok_or(EnvelopeError::MissingVersion)?;
255 let version = parse_wire_version(&version_val)?;
256 // Fail closed on the version *before* interpreting payload as `T`.
257 WireCompatibility::accept(version)?;
258 let payload_val = obj.remove("payload").ok_or(EnvelopeError::MissingPayload)?;
259 let payload = serde_json::from_value(payload_val).map_err(EnvelopeError::Payload)?;
260 Ok(Self {
261 wire_version: version,
262 payload,
263 })
264 }
265}
266
267impl<T: Serialize> WireEnvelope<T> {
268 /// Encode this envelope as compact JSON bytes.
269 pub fn encode_json(&self) -> Result<Vec<u8>, EnvelopeError> {
270 serde_json::to_vec(self).map_err(EnvelopeError::Json)
271 }
272}
273
274impl<'de, T> Deserialize<'de> for WireEnvelope<T>
275where
276 T: DeserializeOwned,
277{
278 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
279 where
280 D: Deserializer<'de>,
281 {
282 let value = Value::deserialize(deserializer)?;
283 Self::from_json_value(value).map_err(D::Error::custom)
284 }
285}
286
287/// Encode `message` as a current-version compatibility envelope.
288pub fn encode_ipc_message_json(message: &IpcMessage) -> Result<Vec<u8>, EnvelopeError> {
289 WireEnvelope::new(message).encode_json()
290}
291
292/// Fail-closed errors from [`encode_canonical_ipc_message`].
293///
294/// This is a **dedicated** encode error, deliberately separate from
295/// [`EnvelopeError`]: its match arms are part of a different contract and must
296/// not be folded into the decode error. Canonical encoding fails before any
297/// bytes are produced, either because the payload did not pass
298/// [`Validate::validate`] (for example a non-finite `f32`, which JSON would
299/// otherwise emit as a silent `null`) or because serialization itself failed.
300#[derive(Debug, thiserror::Error)]
301pub enum CanonicalEncodeError {
302 /// The payload failed [`Validate::validate`] before serialization.
303 ///
304 /// Non-finite floats (`NaN`, `±inf`) are reported here as
305 /// [`ValidationKind::NonFinite`](crate::ValidationKind::NonFinite) rather
306 /// than serialized as `null`.
307 #[error("payload failed validation before canonical encoding: {0}")]
308 Validation(#[from] ValidationError),
309 /// Serialization of the validated envelope failed.
310 #[error("canonical JSON serialization error: {0}")]
311 Json(serde_json::Error),
312}
313
314/// Encode `message` as **canonical** wire-version-1 envelope bytes.
315///
316/// This is the single canonical encoder for the project wire profile
317/// (see `docs/wire-encoding.md`). It is *not* RFC 8785 / JCS; it is this
318/// crate's own profile. Guarantees:
319///
320/// 1. **Validated first.** [`Validate::validate`] runs before serialization,
321/// so a directly constructed non-finite `f32` is rejected with a typed
322/// [`CanonicalEncodeError::Validation`] instead of being emitted as JSON
323/// `null`.
324/// 2. **Deterministic bytes.** Object keys are emitted in sorted (byte-wise)
325/// order at every nesting level, independent of `HashMap` insertion order
326/// or per-process hash seed. The same message therefore encodes to
327/// identical bytes across runs and processes. Determinism is achieved by
328/// routing through [`serde_json::Value`] and calling
329/// [`serde_json::Value::sort_all_objects`], which recursively sorts every
330/// nested object. This does not rely on the default `BTreeMap` backing:
331/// if a consumer's dependency graph enables serde_json's `preserve_order`
332/// feature (unified across the graph by Cargo), `Value` uses an
333/// insertion-ordered `IndexMap` and the explicit sort still applies. The
334/// public [`crate::ConfigPayload`] / [`crate::BatchMetadata`] field types
335/// stay `HashMap`.
336/// 3. **Feature-independent floats.** Every `f32` is widened to `f64`
337/// (`f64::from`) before it is stored as a JSON number, matching default
338/// serde_json `Number` (no `arbitrary_precision`). That keeps bytes stable
339/// if a consumer's dependency graph enables serde_json's
340/// `arbitrary_precision` feature: without the widen, `0.1_f32` encodes as
341/// `0.10000000149011612` by default but `0.1` under that feature.
342/// 4. **Wire version 1.** The payload is wrapped in [`WireEnvelope::new`], so
343/// `wire_version` is [`WireCompatibility::CURRENT`].
344///
345/// Bytes produced here decode through [`decode_ipc_message_json`].
346///
347/// ```
348/// use corpus_ipc::{decode_ipc_message_json, encode_canonical_ipc_message, IpcMessage};
349///
350/// let bytes = encode_canonical_ipc_message(&IpcMessage::Ping).unwrap();
351/// assert_eq!(decode_ipc_message_json(&bytes).unwrap(), IpcMessage::Ping);
352/// ```
353pub fn encode_canonical_ipc_message(message: &IpcMessage) -> Result<Vec<u8>, CanonicalEncodeError> {
354 // 1. Reuse validation as the single pre-serialization gate. This rejects
355 // non-finite floats before any bytes exist (no silent null).
356 message.validate()?;
357 // 2. Reuse the envelope so wire_version == CURRENT (1).
358 let envelope = WireEnvelope::new(message);
359 // 3. Route through Value and sort every nested object's keys. Without
360 // serde_json's `preserve_order` feature, `Value`'s map is already a
361 // sorted `BTreeMap` and `sort_all_objects` is a no-op; with it (which a
362 // consumer's dependency graph can enable via Cargo feature
363 // unification), `Value` becomes an insertion-ordered `IndexMap`, so the
364 // explicit recursive sort is what keeps the bytes independent of
365 // `HashMap` iteration order and the wider feature set.
366 // 4. Widen f32 → f64 before Number so float decimals do not depend on
367 // serde_json `arbitrary_precision` (also Cargo-unified).
368 let mut value =
369 crate::canonical_ser::serialize_f32_as_f64(&envelope, serde_json::value::Serializer)
370 .map_err(CanonicalEncodeError::Json)?;
371 value.sort_all_objects();
372 serde_json::to_vec(&value).map_err(CanonicalEncodeError::Json)
373}
374
375/// Decode an [`IpcMessage`] from JSON, accepting either an envelope or legacy
376/// unversioned tagged JSON.
377///
378/// Envelope path: `{"wire_version":N,"payload":{...}}` — `N` is classified
379/// before the payload is deserialized.
380///
381/// Legacy path: `{"Spikes":{...}}` or `"Ping"` — treated as
382/// [`WireCompatibility::LEGACY_UNVERSIONED`].
383///
384/// ```
385/// use corpus_ipc::{decode_ipc_message_json, encode_ipc_message_json, IpcMessage};
386///
387/// let bytes = encode_ipc_message_json(&IpcMessage::Ping).unwrap();
388/// let message = decode_ipc_message_json(&bytes).unwrap();
389/// assert!(matches!(message, IpcMessage::Ping));
390/// ```
391pub fn decode_ipc_message_json(bytes: &[u8]) -> Result<IpcMessage, EnvelopeError> {
392 match parse_raw_envelope(bytes) {
393 Ok(raw) if matches!(raw.wire_version, OptionalValue::Present(_)) => {
394 Ok(envelope_from_raw_parts::<IpcMessage>(raw)?.into_payload())
395 }
396 Ok(_) => {
397 WireCompatibility::accept(WireCompatibility::LEGACY_UNVERSIONED)?;
398 serde_json::from_slice(bytes).map_err(EnvelopeError::Payload)
399 }
400 Err(EnvelopeError::NotAnObject) => {
401 // Unit-variant strings (`"Ping"`) are not envelopes.
402 let value = serde_json::from_slice(bytes).map_err(EnvelopeError::Json)?;
403 decode_ipc_message_value(value)
404 }
405 Err(EnvelopeError::Json(raw_error)) => {
406 let value = serde_json::from_slice::<Value>(bytes).map_err(EnvelopeError::Json)?;
407 if matches!(&value, Value::Object(obj) if obj.contains_key("wire_version")) {
408 return Err(EnvelopeError::Json(raw_error));
409 }
410 decode_ipc_message_value(value)
411 }
412 Err(other) => Err(other),
413 }
414}
415
416/// [`decode_ipc_message_json`] for an already-parsed [`serde_json::Value`].
417pub fn decode_ipc_message_value(value: Value) -> Result<IpcMessage, EnvelopeError> {
418 match &value {
419 // Externally tagged unit variants (`Ping`, `Shutdown`, `TrainingComplete`)
420 // serialize as a JSON string, not an object.
421 Value::String(_) => {
422 WireCompatibility::accept(WireCompatibility::LEGACY_UNVERSIONED)?;
423 serde_json::from_value(value).map_err(EnvelopeError::Payload)
424 }
425 Value::Object(obj) if obj.contains_key("wire_version") => {
426 Ok(WireEnvelope::<IpcMessage>::from_json_value(value)?.into_payload())
427 }
428 Value::Object(_) => {
429 WireCompatibility::accept(WireCompatibility::LEGACY_UNVERSIONED)?;
430 serde_json::from_value(value).map_err(EnvelopeError::Payload)
431 }
432 _ => Err(EnvelopeError::NotAnObject),
433 }
434}
435
436/// Distinguishes a missing `wire_version` field from an explicit JSON `null`.
437#[derive(Default)]
438enum OptionalValue {
439 #[default]
440 Absent,
441 Present(Value),
442}
443
444impl<'de> Deserialize<'de> for OptionalValue {
445 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
446 where
447 D: Deserializer<'de>,
448 {
449 Ok(Self::Present(Value::deserialize(deserializer)?))
450 }
451}
452
453/// Envelope JSON with the payload left unparsed until the version is accepted.
454#[derive(Deserialize)]
455struct RawEnvelope {
456 #[serde(default)]
457 wire_version: OptionalValue,
458 #[serde(default)]
459 payload: OptionalRaw,
460}
461
462/// Distinguishes a missing `payload` field from an explicit JSON `null`.
463#[derive(Default)]
464enum OptionalRaw {
465 #[default]
466 Absent,
467 Present(Box<RawValue>),
468}
469
470impl<'de> Deserialize<'de> for OptionalRaw {
471 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
472 where
473 D: Deserializer<'de>,
474 {
475 Ok(Self::Present(Box::<RawValue>::deserialize(deserializer)?))
476 }
477}
478
479fn envelope_from_raw_parts<T: DeserializeOwned>(
480 raw: RawEnvelope,
481) -> Result<WireEnvelope<T>, EnvelopeError> {
482 let OptionalValue::Present(version_val) = raw.wire_version else {
483 return Err(EnvelopeError::MissingVersion);
484 };
485 let version = parse_wire_version(&version_val)?;
486 WireCompatibility::accept(version)?;
487 let OptionalRaw::Present(payload_raw) = raw.payload else {
488 return Err(EnvelopeError::MissingPayload);
489 };
490 let payload = serde_json::from_str(payload_raw.get()).map_err(EnvelopeError::Payload)?;
491 Ok(WireEnvelope {
492 wire_version: version,
493 payload,
494 })
495}
496
497fn parse_raw_envelope(bytes: &[u8]) -> Result<RawEnvelope, EnvelopeError> {
498 match bytes.iter().copied().find(|b| !b.is_ascii_whitespace()) {
499 Some(b'{') => serde_json::from_slice(bytes).map_err(EnvelopeError::Json),
500 Some(_) => {
501 if json_is_valid_non_object(bytes) {
502 Err(EnvelopeError::NotAnObject)
503 } else {
504 serde_json::from_slice(bytes).map_err(EnvelopeError::Json)
505 }
506 }
507 None => serde_json::from_slice(bytes).map_err(EnvelopeError::Json),
508 }
509}
510
511fn json_is_valid_non_object(bytes: &[u8]) -> bool {
512 matches!(serde_json::from_slice::<Value>(bytes), Ok(value) if !value.is_object())
513}
514
515fn parse_wire_version(value: &Value) -> Result<u32, EnvelopeError> {
516 match value {
517 Value::Number(n) => n
518 .as_u64()
519 .and_then(|v| u32::try_from(v).ok())
520 .ok_or_else(|| EnvelopeError::InvalidVersion(n.to_string())),
521 other => Err(EnvelopeError::InvalidVersion(other.to_string())),
522 }
523}