Skip to main content

meerkat_live/
wire_input.rs

1//! Shared live-input wire-contract decode owner (D243).
2//!
3//! Every Meerkat live transport — the RPC `live/send_input` JSON-RPC method,
4//! the direct `/live/ws` WebSocket text path, and the WebRTC data channel —
5//! parses client input as the wire-format [`LiveInputChunkWire`]
6//! (`meerkat-contracts`). Supported transport inputs convert into the core
7//! [`LiveInputChunk`] (`meerkat-core::live_adapter`) through this **single**
8//! conversion + validation step before reaching the host. Direct WebSocket
9//! and WebRTC deliberately reject image envelopes before conversion; images
10//! use the JSON-RPC control plane because those transports cannot safely carry
11//! the product's bounded image envelope.
12//!
13//! Previously each direct transport deserialized the core `LiveInputChunk`
14//! shape directly (`serde_json::from_slice::<LiveInputChunk>`), bypassing the
15//! generated `LiveInputChunkWire` boundary that the RPC path runs. That split
16//! meant a payload rejected by `live/send_input` wire validation could still be
17//! accepted on the WebRTC / WS direct paths. This module is the one conversion
18//! owner for transport-supported envelopes. Base64 decode, bounded image
19//! metadata/payload checks, and required idempotency-identity validation all
20//! fail through a typed [`LiveInputChunkDecodeError`] rather than a free-form
21//! provider string.
22
23use base64::Engine;
24use meerkat_contracts::LiveInputChunkWire;
25use meerkat_core::live_adapter::{
26    LiveAdapterErrorCode, LiveConfigRejectionReason, LiveInputChunk, MAX_LIVE_IMAGE_BASE64_BYTES,
27    MAX_LIVE_IMAGE_BYTES, MAX_LIVE_IMAGE_IDEMPOTENCY_KEY_BYTES, MAX_LIVE_IMAGE_MIME_BYTES,
28    live_image_idempotency_key_is_valid,
29};
30
31/// Typed failure decoding a wire-format [`LiveInputChunkWire`] into the core
32/// [`LiveInputChunk`]. Each base64 field that can be malformed lands in a
33/// distinct variant so callers route on the typed class instead of reparsing a
34/// prose string.
35#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum LiveInputChunkDecodeError {
38    /// The audio chunk's base64 `data` field was not valid base64.
39    #[error("invalid base64 in live audio input chunk: {0}")]
40    InvalidAudioBase64(#[source] base64::DecodeError),
41    /// The image chunk's base64 `data` field was not valid base64.
42    #[error("invalid base64 in live image input chunk: {0}")]
43    InvalidImageBase64(#[source] base64::DecodeError),
44    /// The encoded image would exceed the decoded product safety ceiling.
45    /// This check runs before base64 decoding so untrusted transports cannot
46    /// force an arbitrarily large decode allocation.
47    #[error(
48        "live image input encoded payload is too large: {actual_bytes} bytes (maximum {max_bytes})"
49    )]
50    ImageEncodedTooLarge {
51        max_bytes: usize,
52        actual_bytes: usize,
53    },
54    /// Defensive decoded-size backstop. Padded base64 length predicts this
55    /// bound, but the post-decode check keeps the invariant explicit.
56    #[error(
57        "live image input decoded payload is too large: {actual_bytes} bytes (maximum {max_bytes})"
58    )]
59    ImageDecodedTooLarge {
60        max_bytes: usize,
61        actual_bytes: usize,
62    },
63    /// The MIME declaration itself exceeded the bounded metadata envelope.
64    #[error(
65        "live image input MIME declaration is too large: {actual_bytes} bytes (maximum {max_bytes})"
66    )]
67    ImageMimeTooLong {
68        max_bytes: usize,
69        actual_bytes: usize,
70    },
71    /// The required session-scoped image idempotency identity was empty,
72    /// exceeded the bounded metadata envelope, had surrounding whitespace,
73    /// or contained control characters.
74    #[error(
75        "live image idempotency key is invalid: {actual_bytes} bytes (maximum {max_bytes}; empty is not allowed)"
76    )]
77    ImageInputIdempotencyKeyInvalid {
78        max_bytes: usize,
79        actual_bytes: usize,
80    },
81    /// The video-frame chunk's base64 `data` field was not valid base64.
82    #[error("invalid base64 in live video-frame input chunk: {0}")]
83    InvalidVideoFrameBase64(#[source] base64::DecodeError),
84}
85
86/// Decode a wire-format input chunk into the core [`LiveInputChunk`] shape.
87///
88/// This is the canonical conversion owner shared by every live transport so
89/// wire contract validation (required image identity, bounded metadata/payload,
90/// and base64 decoding of `data`) is identical everywhere. The RPC
91/// `live/send_input` handler and the direct WS / WebRTC transports route
92/// through this function.
93pub fn live_input_chunk_from_wire(
94    wire: LiveInputChunkWire,
95) -> Result<LiveInputChunk, LiveInputChunkDecodeError> {
96    match wire {
97        LiveInputChunkWire::Audio {
98            data,
99            sample_rate_hz,
100            channels,
101        } => {
102            let decoded = base64::engine::general_purpose::STANDARD
103                .decode(&data)
104                .map_err(LiveInputChunkDecodeError::InvalidAudioBase64)?;
105            Ok(LiveInputChunk::Audio {
106                data: decoded,
107                sample_rate_hz,
108                channels,
109            })
110        }
111        LiveInputChunkWire::Text { text } => Ok(LiveInputChunk::Text { text }),
112        LiveInputChunkWire::Image {
113            idempotency_key,
114            mime,
115            data,
116        } => {
117            if !live_image_idempotency_key_is_valid(&idempotency_key) {
118                return Err(LiveInputChunkDecodeError::ImageInputIdempotencyKeyInvalid {
119                    max_bytes: MAX_LIVE_IMAGE_IDEMPOTENCY_KEY_BYTES,
120                    actual_bytes: idempotency_key.len(),
121                });
122            }
123            if mime.len() > MAX_LIVE_IMAGE_MIME_BYTES {
124                return Err(LiveInputChunkDecodeError::ImageMimeTooLong {
125                    max_bytes: MAX_LIVE_IMAGE_MIME_BYTES,
126                    actual_bytes: mime.len(),
127                });
128            }
129            if data.len() > MAX_LIVE_IMAGE_BASE64_BYTES {
130                return Err(LiveInputChunkDecodeError::ImageEncodedTooLarge {
131                    max_bytes: MAX_LIVE_IMAGE_BASE64_BYTES,
132                    actual_bytes: data.len(),
133                });
134            }
135            let decoded = base64::engine::general_purpose::STANDARD
136                .decode(&data)
137                .map_err(LiveInputChunkDecodeError::InvalidImageBase64)?;
138            if decoded.len() > MAX_LIVE_IMAGE_BYTES {
139                return Err(LiveInputChunkDecodeError::ImageDecodedTooLarge {
140                    max_bytes: MAX_LIVE_IMAGE_BYTES,
141                    actual_bytes: decoded.len(),
142                });
143            }
144            Ok(LiveInputChunk::Image {
145                idempotency_key,
146                mime,
147                data: decoded,
148            })
149        }
150        LiveInputChunkWire::VideoFrame {
151            codec,
152            data,
153            timestamp_ms,
154        } => {
155            let decoded = base64::engine::general_purpose::STANDARD
156                .decode(&data)
157                .map_err(LiveInputChunkDecodeError::InvalidVideoFrameBase64)?;
158            Ok(LiveInputChunk::VideoFrame {
159                codec,
160                data: decoded,
161                timestamp_ms,
162            })
163        }
164    }
165}
166
167/// Project shared image admission failures onto the scoped typed rejection
168/// used uniformly by RPC, WebSocket, and WebRTC surfaces.
169#[must_use]
170pub fn live_input_chunk_decode_rejection(
171    error: &LiveInputChunkDecodeError,
172) -> Option<LiveAdapterErrorCode> {
173    let reason = match error {
174        LiveInputChunkDecodeError::InvalidImageBase64(_) => {
175            LiveConfigRejectionReason::ImageInputInvalidBase64
176        }
177        LiveInputChunkDecodeError::ImageEncodedTooLarge { actual_bytes, .. } => {
178            LiveConfigRejectionReason::ImageInputTooLarge {
179                max_bytes: MAX_LIVE_IMAGE_BYTES as u64,
180                actual_bytes: actual_bytes.div_ceil(4).saturating_mul(3) as u64,
181            }
182        }
183        LiveInputChunkDecodeError::ImageDecodedTooLarge { actual_bytes, .. } => {
184            LiveConfigRejectionReason::ImageInputTooLarge {
185                max_bytes: MAX_LIVE_IMAGE_BYTES as u64,
186                actual_bytes: *actual_bytes as u64,
187            }
188        }
189        LiveInputChunkDecodeError::ImageMimeTooLong { actual_bytes, .. } => {
190            LiveConfigRejectionReason::ImageInputUnsupportedMime {
191                mime_type: format!("<too-long:{actual_bytes}-bytes>"),
192            }
193        }
194        LiveInputChunkDecodeError::ImageInputIdempotencyKeyInvalid { actual_bytes, .. } => {
195            LiveConfigRejectionReason::ImageInputIdempotencyKeyInvalid {
196                max_bytes: MAX_LIVE_IMAGE_IDEMPOTENCY_KEY_BYTES as u64,
197                actual_bytes: *actual_bytes as u64,
198            }
199        }
200        _ => return None,
201    };
202    Some(LiveAdapterErrorCode::ConfigRejected { reason })
203}
204
205#[cfg(test)]
206#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
207mod tests {
208    use super::*;
209
210    // D243: malformed base64 for a transport-supported envelope is rejected by
211    // the shared conversion owner used by RPC, direct WebSocket, and WebRTC.
212    // Image envelopes reach this conversion through RPC; direct WebSocket and
213    // WebRTC reject that whole input kind at their transport boundary.
214    #[test]
215    fn malformed_audio_base64_is_rejected_with_typed_variant() {
216        let wire = LiveInputChunkWire::Audio {
217            data: "not valid base64!!!".to_string(),
218            sample_rate_hz: 24_000,
219            channels: 1,
220        };
221        match live_input_chunk_from_wire(wire) {
222            Err(LiveInputChunkDecodeError::InvalidAudioBase64(_)) => {}
223            other => panic!("expected InvalidAudioBase64, got {other:?}"),
224        }
225    }
226
227    #[test]
228    fn malformed_image_base64_is_rejected_with_typed_variant() {
229        let wire = LiveInputChunkWire::Image {
230            idempotency_key: "image-request-malformed".to_string(),
231            mime: "image/png".to_string(),
232            data: "@@@not-base64@@@".to_string(),
233        };
234        match live_input_chunk_from_wire(wire) {
235            Err(error @ LiveInputChunkDecodeError::InvalidImageBase64(_)) => {
236                assert!(matches!(
237                    live_input_chunk_decode_rejection(&error),
238                    Some(LiveAdapterErrorCode::ConfigRejected {
239                        reason: LiveConfigRejectionReason::ImageInputInvalidBase64,
240                    })
241                ));
242            }
243            other => panic!("expected InvalidImageBase64, got {other:?}"),
244        }
245    }
246
247    #[test]
248    fn oversized_image_is_rejected_before_base64_decode_allocation() {
249        let wire = LiveInputChunkWire::Image {
250            idempotency_key: "image-request-oversized".to_string(),
251            mime: "image/png".to_string(),
252            data: "A".repeat(MAX_LIVE_IMAGE_BASE64_BYTES + 1),
253        };
254        assert!(matches!(
255            live_input_chunk_from_wire(wire),
256            Err(LiveInputChunkDecodeError::ImageEncodedTooLarge {
257                max_bytes: MAX_LIVE_IMAGE_BASE64_BYTES,
258                actual_bytes,
259            }) if actual_bytes == MAX_LIVE_IMAGE_BASE64_BYTES + 1
260        ));
261    }
262
263    #[test]
264    fn malformed_video_frame_base64_is_rejected_with_typed_variant() {
265        let wire = LiveInputChunkWire::VideoFrame {
266            codec: "vp8".to_string(),
267            data: "%%%".to_string(),
268            timestamp_ms: 42,
269        };
270        match live_input_chunk_from_wire(wire) {
271            Err(LiveInputChunkDecodeError::InvalidVideoFrameBase64(_)) => {}
272            other => panic!("expected InvalidVideoFrameBase64, got {other:?}"),
273        }
274    }
275
276    #[test]
277    fn well_formed_wire_chunks_decode_to_core_chunks() {
278        let encoded = base64::engine::general_purpose::STANDARD.encode([1u8, 2, 3, 4]);
279
280        let audio = live_input_chunk_from_wire(LiveInputChunkWire::Audio {
281            data: encoded.clone(),
282            sample_rate_hz: 24_000,
283            channels: 1,
284        })
285        .expect("valid audio wire chunk decodes");
286        assert!(matches!(
287            audio,
288            LiveInputChunk::Audio { ref data, .. } if data == &[1u8, 2, 3, 4]
289        ));
290
291        let text = live_input_chunk_from_wire(LiveInputChunkWire::Text {
292            text: "hello".to_string(),
293        })
294        .expect("text wire chunk decodes");
295        assert!(matches!(text, LiveInputChunk::Text { text } if text == "hello"));
296
297        let image = live_input_chunk_from_wire(LiveInputChunkWire::Image {
298            idempotency_key: "image-request-valid".to_string(),
299            mime: "image/png".to_string(),
300            data: encoded.clone(),
301        })
302        .expect("valid image wire chunk decodes");
303        assert!(matches!(
304            image,
305            LiveInputChunk::Image {
306                ref mime,
307                ref data,
308                ref idempotency_key,
309            } if mime == "image/png"
310                && data == &[1u8, 2, 3, 4]
311                && idempotency_key == "image-request-valid"
312        ));
313
314        let frame = live_input_chunk_from_wire(LiveInputChunkWire::VideoFrame {
315            codec: "vp8".to_string(),
316            data: encoded,
317            timestamp_ms: 7,
318        })
319        .expect("valid video-frame wire chunk decodes");
320        assert!(matches!(
321            frame,
322            LiveInputChunk::VideoFrame { ref codec, ref data, timestamp_ms }
323                if codec == "vp8" && data == &[1u8, 2, 3, 4] && timestamp_ms == 7
324        ));
325    }
326}