1use std::path::{Path, PathBuf};
44use std::time::Duration;
45
46use serde::{Deserialize, Serialize};
47use thiserror::Error;
48use tokio::io::AsyncWriteExt;
49use tokio::net::UnixStream;
50
51use crate::frame::{encode_frame, FrameError};
52
53pub const CLOUD_PUBLISH_VERSION: u16 = 1;
56
57pub const CLOUD_PUBLISH_SOCK_NAME: &str = "cloud-publish.sock";
59
60const DEFAULT_RUN_DIR: &str = "/run/ados";
62
63pub const SOCKET_MODE: u32 = 0o600;
66
67pub const MAX_FRAME: usize = 80 * 1024;
69
70pub const MAX_PAYLOAD: usize = 64 * 1024;
72
73pub const MAX_RECORD_KEY_BYTES: usize = 256;
75
76pub const MAX_PLUGIN_ID_LEN: usize = 128;
78
79pub const MAX_DEVICE_ID_LEN: usize = 64;
81
82pub const VISION_DETECTIONS_STREAM: &str = "vision.detections";
86
87pub const STREAM_REPLY_TIMEOUT: Duration = Duration::from_secs(2);
90
91pub const RECORD_REPLY_TIMEOUT: Duration = Duration::from_secs(15);
94
95pub fn socket_path() -> PathBuf {
98 match std::env::var("ADOS_RUN_DIR") {
99 Ok(dir) if !dir.trim().is_empty() => Path::new(&dir).join(CLOUD_PUBLISH_SOCK_NAME),
100 _ => Path::new(DEFAULT_RUN_DIR).join(CLOUD_PUBLISH_SOCK_NAME),
101 }
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum CloudPublishKind {
108 Stream,
110 Record,
112}
113
114impl CloudPublishKind {
115 pub fn reply_timeout(self) -> Duration {
117 match self {
118 Self::Stream => STREAM_REPLY_TIMEOUT,
119 Self::Record => RECORD_REPLY_TIMEOUT,
120 }
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct CloudPublishRequest {
127 pub v: u16,
128 pub kind: CloudPublishKind,
129 pub plugin_id: String,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub stream: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub collection: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub key: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub device_id: Option<String>,
141 #[serde(with = "bin")]
142 pub payload: Vec<u8>,
143}
144
145impl CloudPublishRequest {
146 pub fn stream(
148 plugin_id: impl Into<String>,
149 stream: impl Into<String>,
150 payload: Vec<u8>,
151 ) -> Self {
152 Self {
153 v: CLOUD_PUBLISH_VERSION,
154 kind: CloudPublishKind::Stream,
155 plugin_id: plugin_id.into(),
156 stream: Some(stream.into()),
157 collection: None,
158 key: None,
159 device_id: None,
160 payload,
161 }
162 }
163
164 pub fn record(
166 plugin_id: impl Into<String>,
167 collection: impl Into<String>,
168 key: impl Into<String>,
169 payload: Vec<u8>,
170 ) -> Self {
171 Self {
172 v: CLOUD_PUBLISH_VERSION,
173 kind: CloudPublishKind::Record,
174 plugin_id: plugin_id.into(),
175 stream: None,
176 collection: Some(collection.into()),
177 key: Some(key.into()),
178 device_id: None,
179 payload,
180 }
181 }
182
183 pub fn with_device_id(mut self, device_id: impl Into<String>) -> Self {
185 self.device_id = Some(device_id.into());
186 self
187 }
188
189 pub fn validate(&self) -> Result<(), CloudPublishError> {
192 if self.v != CLOUD_PUBLISH_VERSION {
193 return Err(CloudPublishError::Invalid(format!(
194 "unsupported cloud-publish version {} (this build speaks {CLOUD_PUBLISH_VERSION})",
195 self.v
196 )));
197 }
198 validate_plugin_id(&self.plugin_id)?;
199 if self.payload.len() > MAX_PAYLOAD {
200 return Err(CloudPublishError::Invalid(format!(
201 "payload of {} bytes exceeds {MAX_PAYLOAD}",
202 self.payload.len()
203 )));
204 }
205 match self.kind {
206 CloudPublishKind::Stream => {
207 let stream = self
208 .stream
209 .as_deref()
210 .ok_or_else(|| invalid("a stream request needs a stream name"))?;
211 validate_stream_name(stream)?;
212 if self.collection.is_some() || self.key.is_some() || self.device_id.is_some() {
213 return Err(invalid(
214 "a stream request carries no collection, key or device_id",
215 ));
216 }
217 }
218 CloudPublishKind::Record => {
219 let collection = self
220 .collection
221 .as_deref()
222 .ok_or_else(|| invalid("a record request needs a collection"))?;
223 validate_collection(collection)?;
224 let key = self
225 .key
226 .as_deref()
227 .ok_or_else(|| invalid("a record request needs a key"))?;
228 validate_record_key(key)?;
229 if let Some(device_id) = self.device_id.as_deref() {
230 validate_device_id(device_id)?;
231 }
232 if self.stream.is_some() {
233 return Err(invalid("a record request carries no stream name"));
234 }
235 }
236 }
237 Ok(())
238 }
239
240 pub fn encode(&self) -> Result<Vec<u8>, CloudPublishError> {
242 self.validate()?;
243 let body =
244 rmp_serde::to_vec_named(self).map_err(|e| CloudPublishError::Encode(e.to_string()))?;
245 frame_body(&body)
246 }
247
248 pub fn decode(body: &[u8]) -> Result<Self, CloudPublishError> {
250 let req: Self =
251 rmp_serde::from_slice(body).map_err(|e| CloudPublishError::Decode(e.to_string()))?;
252 req.validate()?;
253 Ok(req)
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
259pub struct CloudPublishReply {
260 pub ok: bool,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub error: Option<String>,
263}
264
265impl CloudPublishReply {
266 pub fn accepted() -> Self {
268 Self {
269 ok: true,
270 error: None,
271 }
272 }
273
274 pub fn refused(reason: impl Into<String>) -> Self {
276 Self {
277 ok: false,
278 error: Some(reason.into()),
279 }
280 }
281
282 pub fn encode(&self) -> Result<Vec<u8>, CloudPublishError> {
284 let body =
285 rmp_serde::to_vec_named(self).map_err(|e| CloudPublishError::Encode(e.to_string()))?;
286 frame_body(&body)
287 }
288
289 pub fn decode(body: &[u8]) -> Result<Self, CloudPublishError> {
291 rmp_serde::from_slice(body).map_err(|e| CloudPublishError::Decode(e.to_string()))
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq, Error)]
297pub enum CloudPublishError {
298 #[error("invalid cloud-publish request: {0}")]
300 Invalid(String),
301 #[error("cloud-publish encode failed: {0}")]
302 Encode(String),
303 #[error("cloud-publish decode failed: {0}")]
304 Decode(String),
305 #[error("cloud-publish socket: {0}")]
307 Io(String),
308 #[error("cloud-publish reply timed out")]
309 Timeout,
310}
311
312fn invalid(msg: &str) -> CloudPublishError {
313 CloudPublishError::Invalid(msg.to_string())
314}
315
316fn frame_body(body: &[u8]) -> Result<Vec<u8>, CloudPublishError> {
317 encode_frame(body, MAX_FRAME).map_err(|e| match e {
318 FrameError::TooLarge { len, max } => {
319 CloudPublishError::Encode(format!("frame of {len} bytes exceeds {max}"))
320 }
321 other => CloudPublishError::Encode(other.to_string()),
322 })
323}
324
325pub async fn send(
330 path: &Path,
331 req: &CloudPublishRequest,
332) -> Result<CloudPublishReply, CloudPublishError> {
333 let frame = req.encode()?;
334 tokio::time::timeout(req.kind.reply_timeout(), exchange(path, &frame))
335 .await
336 .map_err(|_| CloudPublishError::Timeout)?
337}
338
339async fn exchange(path: &Path, frame: &[u8]) -> Result<CloudPublishReply, CloudPublishError> {
340 let io = |e: std::io::Error| CloudPublishError::Io(e.to_string());
341 let mut stream = UnixStream::connect(path).await.map_err(io)?;
342 stream.write_all(frame).await.map_err(io)?;
343 let body = crate::ipc::read_length_prefixed(&mut stream, MAX_FRAME, true)
344 .await
345 .map_err(io)?
346 .ok_or_else(|| CloudPublishError::Io("relay closed the connection".into()))?;
347 CloudPublishReply::decode(&body)
348}
349
350pub fn validate_plugin_id(id: &str) -> Result<(), CloudPublishError> {
354 let ok = id.len() <= MAX_PLUGIN_ID_LEN
355 && id.split_once('.').is_some_and(|(head, tail)| {
356 !head.is_empty()
357 && head.bytes().all(is_lower_alnum)
358 && tail
359 .split('.')
360 .all(|s| !s.is_empty() && s.bytes().all(|b| is_lower_alnum(b) || b == b'-'))
361 });
362 if ok {
363 Ok(())
364 } else {
365 Err(CloudPublishError::Invalid(format!(
366 "plugin id {id:?} must be reverse-DNS lowercase, at most {MAX_PLUGIN_ID_LEN} characters"
367 )))
368 }
369}
370
371pub fn validate_stream_name(stream: &str) -> Result<(), CloudPublishError> {
373 let bytes = stream.as_bytes();
374 let ok = (1..=64).contains(&bytes.len())
375 && is_lower_alnum(bytes[0])
376 && bytes[1..]
377 .iter()
378 .all(|&b| is_lower_alnum(b) || matches!(b, b'.' | b'_' | b'-'));
379 if ok {
380 Ok(())
381 } else {
382 Err(CloudPublishError::Invalid(format!(
383 "stream name {stream:?} must match [a-z0-9][a-z0-9._-]{{0,63}}"
384 )))
385 }
386}
387
388pub fn validate_collection(collection: &str) -> Result<(), CloudPublishError> {
390 let ok = (1..=64).contains(&collection.len())
391 && collection
392 .bytes()
393 .all(|b| is_lower_alnum(b) || matches!(b, b'.' | b'_' | b'-'));
394 if ok {
395 Ok(())
396 } else {
397 Err(CloudPublishError::Invalid(format!(
398 "collection {collection:?} must match [a-z0-9_.-]{{1,64}}"
399 )))
400 }
401}
402
403pub fn validate_record_key(key: &str) -> Result<(), CloudPublishError> {
405 if !key.is_empty() && key.len() <= MAX_RECORD_KEY_BYTES {
406 Ok(())
407 } else {
408 Err(CloudPublishError::Invalid(format!(
409 "record key must be 1-{MAX_RECORD_KEY_BYTES} bytes, got {}",
410 key.len()
411 )))
412 }
413}
414
415pub fn validate_device_id(device_id: &str) -> Result<(), CloudPublishError> {
417 let ok = (1..=MAX_DEVICE_ID_LEN).contains(&device_id.len())
418 && device_id
419 .bytes()
420 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
421 if ok {
422 Ok(())
423 } else {
424 Err(CloudPublishError::Invalid(format!(
425 "device id {device_id:?} must match [A-Za-z0-9._-]{{1,{MAX_DEVICE_ID_LEN}}}"
426 )))
427 }
428}
429
430fn is_lower_alnum(b: u8) -> bool {
431 b.is_ascii_lowercase() || b.is_ascii_digit()
432}
433
434mod bin {
436 use serde::{de, Deserializer, Serializer};
437
438 pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
439 s.serialize_bytes(bytes)
440 }
441
442 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
443 struct BinVisitor;
444
445 impl<'de> de::Visitor<'de> for BinVisitor {
446 type Value = Vec<u8>;
447
448 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
449 f.write_str("a binary payload")
450 }
451
452 fn visit_bytes<E: de::Error>(self, v: &[u8]) -> Result<Vec<u8>, E> {
453 Ok(v.to_vec())
454 }
455
456 fn visit_byte_buf<E: de::Error>(self, v: Vec<u8>) -> Result<Vec<u8>, E> {
457 Ok(v)
458 }
459 }
460
461 d.deserialize_byte_buf(BinVisitor)
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use tokio::io::AsyncReadExt;
469 use tokio::net::UnixListener;
470
471 fn body_of(frame: &[u8]) -> &[u8] {
472 let len = u32::from_be_bytes(frame[..4].try_into().unwrap()) as usize;
473 assert_eq!(frame.len(), 4 + len, "one whole frame");
474 &frame[4..]
475 }
476
477 #[test]
478 fn version_matches_the_contract_registry() {
479 assert_eq!(
480 Some(CLOUD_PUBLISH_VERSION),
481 crate::contracts::contract_version("cloud-publish")
482 );
483 }
484
485 #[test]
486 fn a_stream_request_round_trips_with_a_binary_payload() {
487 let req = CloudPublishRequest::stream("com.example.mapper", "pose", vec![0, 1, 2, 255]);
488 let frame = req.encode().unwrap();
489 let body = body_of(&frame);
490
491 let raw: rmpv::Value = rmp_serde::from_slice(body).unwrap();
494 let map: Vec<(String, rmpv::Value)> = raw
495 .as_map()
496 .unwrap()
497 .iter()
498 .map(|(k, v)| (k.as_str().unwrap().to_string(), v.clone()))
499 .collect();
500 let get = |k: &str| map.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
501 assert_eq!(get("v"), Some(rmpv::Value::from(1)));
502 assert_eq!(get("kind"), Some(rmpv::Value::from("stream")));
503 assert_eq!(
504 get("plugin_id"),
505 Some(rmpv::Value::from("com.example.mapper"))
506 );
507 assert_eq!(get("stream"), Some(rmpv::Value::from("pose")));
508 assert_eq!(
509 get("payload"),
510 Some(rmpv::Value::Binary(vec![0, 1, 2, 255]))
511 );
512 assert_eq!(get("collection"), None, "absent optionals are not sent");
513
514 assert_eq!(CloudPublishRequest::decode(body).unwrap(), req);
515 }
516
517 #[test]
518 fn a_record_request_round_trips_with_its_subject_device() {
519 let req = CloudPublishRequest::record(
520 "com.example.mapper",
521 "jobs",
522 "session-7",
523 br#"{"status":"done"}"#.to_vec(),
524 )
525 .with_device_id("a1b2c3d4e5f6");
526 let frame = req.encode().unwrap();
527 assert_eq!(CloudPublishRequest::decode(body_of(&frame)).unwrap(), req);
528 }
529
530 #[test]
531 fn a_reply_round_trips_in_both_shapes() {
532 for reply in [
533 CloudPublishReply::accepted(),
534 CloudPublishReply::refused("cloud relay is not paired"),
535 ] {
536 let frame = reply.encode().unwrap();
537 assert_eq!(CloudPublishReply::decode(body_of(&frame)).unwrap(), reply);
538 }
539 let raw: rmpv::Value =
541 rmp_serde::from_slice(body_of(&CloudPublishReply::accepted().encode().unwrap()))
542 .unwrap();
543 assert_eq!(raw.as_map().unwrap().len(), 1);
544 }
545
546 #[test]
547 fn a_payload_over_the_cap_is_refused_and_one_at_the_cap_is_not() {
548 let at_cap = CloudPublishRequest::stream("com.example.a", "s", vec![7; MAX_PAYLOAD]);
549 let frame = at_cap
550 .encode()
551 .expect("a payload at the cap fits the frame");
552 assert!(frame.len() - 4 <= MAX_FRAME);
553
554 let over = CloudPublishRequest::stream("com.example.a", "s", vec![7; MAX_PAYLOAD + 1]);
555 assert!(matches!(over.encode(), Err(CloudPublishError::Invalid(_))));
556 }
557
558 #[test]
559 fn decode_refuses_an_oversize_payload_even_from_a_hand_built_frame() {
560 let mut over = CloudPublishRequest::stream("com.example.a", "s", Vec::new());
561 over.payload = vec![0; MAX_PAYLOAD + 1];
562 let body = rmp_serde::to_vec_named(&over).unwrap();
563 assert!(matches!(
564 CloudPublishRequest::decode(&body),
565 Err(CloudPublishError::Invalid(_))
566 ));
567 }
568
569 #[test]
570 fn decode_refuses_another_version() {
571 let mut req = CloudPublishRequest::stream("com.example.a", "s", Vec::new());
572 req.v = CLOUD_PUBLISH_VERSION + 1;
573 let body = rmp_serde::to_vec_named(&req).unwrap();
574 assert!(matches!(
575 CloudPublishRequest::decode(&body),
576 Err(CloudPublishError::Invalid(_))
577 ));
578 }
579
580 #[test]
581 fn a_kind_must_carry_exactly_its_own_fields() {
582 let mut stream_without_name = CloudPublishRequest::stream("com.example.a", "s", vec![]);
583 stream_without_name.stream = None;
584 let mut stream_with_key = CloudPublishRequest::stream("com.example.a", "s", vec![]);
585 stream_with_key.key = Some("k".into());
586 let stream_with_device =
587 CloudPublishRequest::stream("com.example.a", "s", vec![]).with_device_id("dev1");
588 let mut record_without_key = CloudPublishRequest::record("com.example.a", "c", "k", vec![]);
589 record_without_key.key = None;
590 let mut record_with_stream = CloudPublishRequest::record("com.example.a", "c", "k", vec![]);
591 record_with_stream.stream = Some("s".into());
592
593 for req in [
594 stream_without_name,
595 stream_with_key,
596 stream_with_device,
597 record_without_key,
598 record_with_stream,
599 ] {
600 assert!(req.validate().is_err(), "{req:?}");
601 }
602 }
603
604 #[test]
605 fn stream_names_follow_the_grammar() {
606 for good in ["a", "0", "pose", "map.keyframe", "a_b-c.d", &"a".repeat(64)] {
607 assert!(validate_stream_name(good).is_ok(), "{good}");
608 }
609 for bad in [
610 "",
611 ".pose",
612 "-pose",
613 "_pose",
614 "Pose",
615 "a/b",
616 "a+b",
617 "a#",
618 "a b",
619 &"a".repeat(65),
620 ] {
621 assert!(validate_stream_name(bad).is_err(), "{bad}");
622 }
623 }
624
625 #[test]
626 fn collections_follow_the_grammar() {
627 for good in ["jobs", "a", ".x", "_x", "a.b_c-d", &"z".repeat(64)] {
628 assert!(validate_collection(good).is_ok(), "{good}");
629 }
630 for bad in ["", "Jobs", "a/b", "a b", &"z".repeat(65)] {
631 assert!(validate_collection(bad).is_err(), "{bad}");
632 }
633 }
634
635 #[test]
636 fn record_keys_are_capped_in_bytes_not_characters() {
637 assert!(validate_record_key("k").is_ok());
638 assert!(validate_record_key(&"k".repeat(MAX_RECORD_KEY_BYTES)).is_ok());
639 assert!(validate_record_key("").is_err());
640 assert!(validate_record_key(&"k".repeat(MAX_RECORD_KEY_BYTES + 1)).is_err());
641 assert!(validate_record_key(&"€".repeat(86)).is_err());
643 assert!(validate_record_key(&"€".repeat(85)).is_ok());
644 }
645
646 #[test]
647 fn plugin_ids_are_reverse_dns_and_topic_safe() {
648 for good in ["com.example.a", "a.b", "com.example.multi-word", "x9.y-z.q"] {
649 assert!(validate_plugin_id(good).is_ok(), "{good}");
650 }
651 for bad in [
652 "",
653 "single",
654 "com.",
655 ".com.x",
656 "com..x",
657 "Com.example.a",
658 "com-x.example",
659 "com.example/a",
660 "com.example.+",
661 "com.example.#",
662 ] {
663 assert!(validate_plugin_id(bad).is_err(), "{bad}");
664 }
665 let long = format!("com.{}", "a".repeat(MAX_PLUGIN_ID_LEN));
666 assert!(validate_plugin_id(&long).is_err());
667 }
668
669 #[test]
670 fn subject_device_ids_are_bounded_tokens() {
671 assert!(validate_device_id("a1b2c3d4e5f6").is_ok());
672 for bad in ["", "a/b", "a b", &"d".repeat(MAX_DEVICE_ID_LEN + 1)] {
673 assert!(validate_device_id(bad).is_err(), "{bad}");
674 }
675 }
676
677 #[tokio::test]
678 async fn send_writes_one_frame_and_returns_the_relays_reply() {
679 let dir = tempfile::tempdir().unwrap();
680 let path = dir.path().join(CLOUD_PUBLISH_SOCK_NAME);
681 let listener = UnixListener::bind(&path).unwrap();
682 let server = tokio::spawn(async move {
683 let (mut s, _) = listener.accept().await.unwrap();
684 let body = crate::ipc::read_length_prefixed(&mut s, MAX_FRAME, true)
685 .await
686 .unwrap()
687 .unwrap();
688 let req = CloudPublishRequest::decode(&body).unwrap();
689 let reply = CloudPublishReply::refused(format!("saw {}", req.plugin_id));
690 s.write_all(&reply.encode().unwrap()).await.unwrap();
691 let mut rest = Vec::new();
693 s.read_to_end(&mut rest).await.unwrap();
694 assert!(rest.is_empty());
695 });
696
697 let reply = send(
698 &path,
699 &CloudPublishRequest::stream("com.example.a", "s", b"x".to_vec()),
700 )
701 .await
702 .unwrap();
703 assert_eq!(reply, CloudPublishReply::refused("saw com.example.a"));
704 server.await.unwrap();
705 }
706
707 #[tokio::test]
708 async fn send_refuses_an_invalid_request_without_connecting() {
709 let dir = tempfile::tempdir().unwrap();
712 let req = CloudPublishRequest::stream("com.example.a", "Bad Name", vec![]);
713 assert!(matches!(
714 send(&dir.path().join("absent.sock"), &req).await,
715 Err(CloudPublishError::Invalid(_))
716 ));
717 }
718
719 #[tokio::test]
720 async fn send_reports_an_absent_socket_as_io() {
721 let dir = tempfile::tempdir().unwrap();
722 let req = CloudPublishRequest::stream("com.example.a", "s", vec![]);
723 assert!(matches!(
724 send(&dir.path().join("absent.sock"), &req).await,
725 Err(CloudPublishError::Io(_))
726 ));
727 }
728}