Skip to main content

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}