1use 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#[derive(Debug, thiserror::Error)]
36#[non_exhaustive]
37pub enum LiveInputChunkDecodeError {
38 #[error("invalid base64 in live audio input chunk: {0}")]
40 InvalidAudioBase64(#[source] base64::DecodeError),
41 #[error("invalid base64 in live image input chunk: {0}")]
43 InvalidImageBase64(#[source] base64::DecodeError),
44 #[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 #[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 #[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 #[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 #[error("invalid base64 in live video-frame input chunk: {0}")]
83 InvalidVideoFrameBase64(#[source] base64::DecodeError),
84}
85
86pub 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#[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 #[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}