use std::path::{Path, PathBuf};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::io::AsyncWriteExt;
use tokio::net::UnixStream;
use crate::frame::{encode_frame, FrameError};
pub const CLOUD_PUBLISH_VERSION: u16 = 1;
pub const CLOUD_PUBLISH_SOCK_NAME: &str = "cloud-publish.sock";
const DEFAULT_RUN_DIR: &str = "/run/ados";
pub const SOCKET_MODE: u32 = 0o600;
pub const MAX_FRAME: usize = 80 * 1024;
pub const MAX_PAYLOAD: usize = 64 * 1024;
pub const MAX_RECORD_KEY_BYTES: usize = 256;
pub const MAX_PLUGIN_ID_LEN: usize = 128;
pub const MAX_DEVICE_ID_LEN: usize = 64;
pub const VISION_DETECTIONS_STREAM: &str = "vision.detections";
pub const STREAM_REPLY_TIMEOUT: Duration = Duration::from_secs(2);
pub const RECORD_REPLY_TIMEOUT: Duration = Duration::from_secs(15);
pub fn socket_path() -> PathBuf {
match std::env::var("ADOS_RUN_DIR") {
Ok(dir) if !dir.trim().is_empty() => Path::new(&dir).join(CLOUD_PUBLISH_SOCK_NAME),
_ => Path::new(DEFAULT_RUN_DIR).join(CLOUD_PUBLISH_SOCK_NAME),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CloudPublishKind {
Stream,
Record,
}
impl CloudPublishKind {
pub fn reply_timeout(self) -> Duration {
match self {
Self::Stream => STREAM_REPLY_TIMEOUT,
Self::Record => RECORD_REPLY_TIMEOUT,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CloudPublishRequest {
pub v: u16,
pub kind: CloudPublishKind,
pub plugin_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub collection: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device_id: Option<String>,
#[serde(with = "bin")]
pub payload: Vec<u8>,
}
impl CloudPublishRequest {
pub fn stream(
plugin_id: impl Into<String>,
stream: impl Into<String>,
payload: Vec<u8>,
) -> Self {
Self {
v: CLOUD_PUBLISH_VERSION,
kind: CloudPublishKind::Stream,
plugin_id: plugin_id.into(),
stream: Some(stream.into()),
collection: None,
key: None,
device_id: None,
payload,
}
}
pub fn record(
plugin_id: impl Into<String>,
collection: impl Into<String>,
key: impl Into<String>,
payload: Vec<u8>,
) -> Self {
Self {
v: CLOUD_PUBLISH_VERSION,
kind: CloudPublishKind::Record,
plugin_id: plugin_id.into(),
stream: None,
collection: Some(collection.into()),
key: Some(key.into()),
device_id: None,
payload,
}
}
pub fn with_device_id(mut self, device_id: impl Into<String>) -> Self {
self.device_id = Some(device_id.into());
self
}
pub fn validate(&self) -> Result<(), CloudPublishError> {
if self.v != CLOUD_PUBLISH_VERSION {
return Err(CloudPublishError::Invalid(format!(
"unsupported cloud-publish version {} (this build speaks {CLOUD_PUBLISH_VERSION})",
self.v
)));
}
validate_plugin_id(&self.plugin_id)?;
if self.payload.len() > MAX_PAYLOAD {
return Err(CloudPublishError::Invalid(format!(
"payload of {} bytes exceeds {MAX_PAYLOAD}",
self.payload.len()
)));
}
match self.kind {
CloudPublishKind::Stream => {
let stream = self
.stream
.as_deref()
.ok_or_else(|| invalid("a stream request needs a stream name"))?;
validate_stream_name(stream)?;
if self.collection.is_some() || self.key.is_some() || self.device_id.is_some() {
return Err(invalid(
"a stream request carries no collection, key or device_id",
));
}
}
CloudPublishKind::Record => {
let collection = self
.collection
.as_deref()
.ok_or_else(|| invalid("a record request needs a collection"))?;
validate_collection(collection)?;
let key = self
.key
.as_deref()
.ok_or_else(|| invalid("a record request needs a key"))?;
validate_record_key(key)?;
if let Some(device_id) = self.device_id.as_deref() {
validate_device_id(device_id)?;
}
if self.stream.is_some() {
return Err(invalid("a record request carries no stream name"));
}
}
}
Ok(())
}
pub fn encode(&self) -> Result<Vec<u8>, CloudPublishError> {
self.validate()?;
let body =
rmp_serde::to_vec_named(self).map_err(|e| CloudPublishError::Encode(e.to_string()))?;
frame_body(&body)
}
pub fn decode(body: &[u8]) -> Result<Self, CloudPublishError> {
let req: Self =
rmp_serde::from_slice(body).map_err(|e| CloudPublishError::Decode(e.to_string()))?;
req.validate()?;
Ok(req)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CloudPublishReply {
pub ok: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl CloudPublishReply {
pub fn accepted() -> Self {
Self {
ok: true,
error: None,
}
}
pub fn refused(reason: impl Into<String>) -> Self {
Self {
ok: false,
error: Some(reason.into()),
}
}
pub fn encode(&self) -> Result<Vec<u8>, CloudPublishError> {
let body =
rmp_serde::to_vec_named(self).map_err(|e| CloudPublishError::Encode(e.to_string()))?;
frame_body(&body)
}
pub fn decode(body: &[u8]) -> Result<Self, CloudPublishError> {
rmp_serde::from_slice(body).map_err(|e| CloudPublishError::Decode(e.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum CloudPublishError {
#[error("invalid cloud-publish request: {0}")]
Invalid(String),
#[error("cloud-publish encode failed: {0}")]
Encode(String),
#[error("cloud-publish decode failed: {0}")]
Decode(String),
#[error("cloud-publish socket: {0}")]
Io(String),
#[error("cloud-publish reply timed out")]
Timeout,
}
fn invalid(msg: &str) -> CloudPublishError {
CloudPublishError::Invalid(msg.to_string())
}
fn frame_body(body: &[u8]) -> Result<Vec<u8>, CloudPublishError> {
encode_frame(body, MAX_FRAME).map_err(|e| match e {
FrameError::TooLarge { len, max } => {
CloudPublishError::Encode(format!("frame of {len} bytes exceeds {max}"))
}
other => CloudPublishError::Encode(other.to_string()),
})
}
pub async fn send(
path: &Path,
req: &CloudPublishRequest,
) -> Result<CloudPublishReply, CloudPublishError> {
let frame = req.encode()?;
tokio::time::timeout(req.kind.reply_timeout(), exchange(path, &frame))
.await
.map_err(|_| CloudPublishError::Timeout)?
}
async fn exchange(path: &Path, frame: &[u8]) -> Result<CloudPublishReply, CloudPublishError> {
let io = |e: std::io::Error| CloudPublishError::Io(e.to_string());
let mut stream = UnixStream::connect(path).await.map_err(io)?;
stream.write_all(frame).await.map_err(io)?;
let body = crate::ipc::read_length_prefixed(&mut stream, MAX_FRAME, true)
.await
.map_err(io)?
.ok_or_else(|| CloudPublishError::Io("relay closed the connection".into()))?;
CloudPublishReply::decode(&body)
}
pub fn validate_plugin_id(id: &str) -> Result<(), CloudPublishError> {
let ok = id.len() <= MAX_PLUGIN_ID_LEN
&& id.split_once('.').is_some_and(|(head, tail)| {
!head.is_empty()
&& head.bytes().all(is_lower_alnum)
&& tail
.split('.')
.all(|s| !s.is_empty() && s.bytes().all(|b| is_lower_alnum(b) || b == b'-'))
});
if ok {
Ok(())
} else {
Err(CloudPublishError::Invalid(format!(
"plugin id {id:?} must be reverse-DNS lowercase, at most {MAX_PLUGIN_ID_LEN} characters"
)))
}
}
pub fn validate_stream_name(stream: &str) -> Result<(), CloudPublishError> {
let bytes = stream.as_bytes();
let ok = (1..=64).contains(&bytes.len())
&& is_lower_alnum(bytes[0])
&& bytes[1..]
.iter()
.all(|&b| is_lower_alnum(b) || matches!(b, b'.' | b'_' | b'-'));
if ok {
Ok(())
} else {
Err(CloudPublishError::Invalid(format!(
"stream name {stream:?} must match [a-z0-9][a-z0-9._-]{{0,63}}"
)))
}
}
pub fn validate_collection(collection: &str) -> Result<(), CloudPublishError> {
let ok = (1..=64).contains(&collection.len())
&& collection
.bytes()
.all(|b| is_lower_alnum(b) || matches!(b, b'.' | b'_' | b'-'));
if ok {
Ok(())
} else {
Err(CloudPublishError::Invalid(format!(
"collection {collection:?} must match [a-z0-9_.-]{{1,64}}"
)))
}
}
pub fn validate_record_key(key: &str) -> Result<(), CloudPublishError> {
if !key.is_empty() && key.len() <= MAX_RECORD_KEY_BYTES {
Ok(())
} else {
Err(CloudPublishError::Invalid(format!(
"record key must be 1-{MAX_RECORD_KEY_BYTES} bytes, got {}",
key.len()
)))
}
}
pub fn validate_device_id(device_id: &str) -> Result<(), CloudPublishError> {
let ok = (1..=MAX_DEVICE_ID_LEN).contains(&device_id.len())
&& device_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
if ok {
Ok(())
} else {
Err(CloudPublishError::Invalid(format!(
"device id {device_id:?} must match [A-Za-z0-9._-]{{1,{MAX_DEVICE_ID_LEN}}}"
)))
}
}
fn is_lower_alnum(b: u8) -> bool {
b.is_ascii_lowercase() || b.is_ascii_digit()
}
mod bin {
use serde::{de, Deserializer, Serializer};
pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
s.serialize_bytes(bytes)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
struct BinVisitor;
impl<'de> de::Visitor<'de> for BinVisitor {
type Value = Vec<u8>;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a binary payload")
}
fn visit_bytes<E: de::Error>(self, v: &[u8]) -> Result<Vec<u8>, E> {
Ok(v.to_vec())
}
fn visit_byte_buf<E: de::Error>(self, v: Vec<u8>) -> Result<Vec<u8>, E> {
Ok(v)
}
}
d.deserialize_byte_buf(BinVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncReadExt;
use tokio::net::UnixListener;
fn body_of(frame: &[u8]) -> &[u8] {
let len = u32::from_be_bytes(frame[..4].try_into().unwrap()) as usize;
assert_eq!(frame.len(), 4 + len, "one whole frame");
&frame[4..]
}
#[test]
fn version_matches_the_contract_registry() {
assert_eq!(
Some(CLOUD_PUBLISH_VERSION),
crate::contracts::contract_version("cloud-publish")
);
}
#[test]
fn a_stream_request_round_trips_with_a_binary_payload() {
let req = CloudPublishRequest::stream("com.example.mapper", "pose", vec![0, 1, 2, 255]);
let frame = req.encode().unwrap();
let body = body_of(&frame);
let raw: rmpv::Value = rmp_serde::from_slice(body).unwrap();
let map: Vec<(String, rmpv::Value)> = raw
.as_map()
.unwrap()
.iter()
.map(|(k, v)| (k.as_str().unwrap().to_string(), v.clone()))
.collect();
let get = |k: &str| map.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone());
assert_eq!(get("v"), Some(rmpv::Value::from(1)));
assert_eq!(get("kind"), Some(rmpv::Value::from("stream")));
assert_eq!(
get("plugin_id"),
Some(rmpv::Value::from("com.example.mapper"))
);
assert_eq!(get("stream"), Some(rmpv::Value::from("pose")));
assert_eq!(
get("payload"),
Some(rmpv::Value::Binary(vec![0, 1, 2, 255]))
);
assert_eq!(get("collection"), None, "absent optionals are not sent");
assert_eq!(CloudPublishRequest::decode(body).unwrap(), req);
}
#[test]
fn a_record_request_round_trips_with_its_subject_device() {
let req = CloudPublishRequest::record(
"com.example.mapper",
"jobs",
"session-7",
br#"{"status":"done"}"#.to_vec(),
)
.with_device_id("a1b2c3d4e5f6");
let frame = req.encode().unwrap();
assert_eq!(CloudPublishRequest::decode(body_of(&frame)).unwrap(), req);
}
#[test]
fn a_reply_round_trips_in_both_shapes() {
for reply in [
CloudPublishReply::accepted(),
CloudPublishReply::refused("cloud relay is not paired"),
] {
let frame = reply.encode().unwrap();
assert_eq!(CloudPublishReply::decode(body_of(&frame)).unwrap(), reply);
}
let raw: rmpv::Value =
rmp_serde::from_slice(body_of(&CloudPublishReply::accepted().encode().unwrap()))
.unwrap();
assert_eq!(raw.as_map().unwrap().len(), 1);
}
#[test]
fn a_payload_over_the_cap_is_refused_and_one_at_the_cap_is_not() {
let at_cap = CloudPublishRequest::stream("com.example.a", "s", vec![7; MAX_PAYLOAD]);
let frame = at_cap
.encode()
.expect("a payload at the cap fits the frame");
assert!(frame.len() - 4 <= MAX_FRAME);
let over = CloudPublishRequest::stream("com.example.a", "s", vec![7; MAX_PAYLOAD + 1]);
assert!(matches!(over.encode(), Err(CloudPublishError::Invalid(_))));
}
#[test]
fn decode_refuses_an_oversize_payload_even_from_a_hand_built_frame() {
let mut over = CloudPublishRequest::stream("com.example.a", "s", Vec::new());
over.payload = vec![0; MAX_PAYLOAD + 1];
let body = rmp_serde::to_vec_named(&over).unwrap();
assert!(matches!(
CloudPublishRequest::decode(&body),
Err(CloudPublishError::Invalid(_))
));
}
#[test]
fn decode_refuses_another_version() {
let mut req = CloudPublishRequest::stream("com.example.a", "s", Vec::new());
req.v = CLOUD_PUBLISH_VERSION + 1;
let body = rmp_serde::to_vec_named(&req).unwrap();
assert!(matches!(
CloudPublishRequest::decode(&body),
Err(CloudPublishError::Invalid(_))
));
}
#[test]
fn a_kind_must_carry_exactly_its_own_fields() {
let mut stream_without_name = CloudPublishRequest::stream("com.example.a", "s", vec![]);
stream_without_name.stream = None;
let mut stream_with_key = CloudPublishRequest::stream("com.example.a", "s", vec![]);
stream_with_key.key = Some("k".into());
let stream_with_device =
CloudPublishRequest::stream("com.example.a", "s", vec![]).with_device_id("dev1");
let mut record_without_key = CloudPublishRequest::record("com.example.a", "c", "k", vec![]);
record_without_key.key = None;
let mut record_with_stream = CloudPublishRequest::record("com.example.a", "c", "k", vec![]);
record_with_stream.stream = Some("s".into());
for req in [
stream_without_name,
stream_with_key,
stream_with_device,
record_without_key,
record_with_stream,
] {
assert!(req.validate().is_err(), "{req:?}");
}
}
#[test]
fn stream_names_follow_the_grammar() {
for good in ["a", "0", "pose", "map.keyframe", "a_b-c.d", &"a".repeat(64)] {
assert!(validate_stream_name(good).is_ok(), "{good}");
}
for bad in [
"",
".pose",
"-pose",
"_pose",
"Pose",
"a/b",
"a+b",
"a#",
"a b",
&"a".repeat(65),
] {
assert!(validate_stream_name(bad).is_err(), "{bad}");
}
}
#[test]
fn collections_follow_the_grammar() {
for good in ["jobs", "a", ".x", "_x", "a.b_c-d", &"z".repeat(64)] {
assert!(validate_collection(good).is_ok(), "{good}");
}
for bad in ["", "Jobs", "a/b", "a b", &"z".repeat(65)] {
assert!(validate_collection(bad).is_err(), "{bad}");
}
}
#[test]
fn record_keys_are_capped_in_bytes_not_characters() {
assert!(validate_record_key("k").is_ok());
assert!(validate_record_key(&"k".repeat(MAX_RECORD_KEY_BYTES)).is_ok());
assert!(validate_record_key("").is_err());
assert!(validate_record_key(&"k".repeat(MAX_RECORD_KEY_BYTES + 1)).is_err());
assert!(validate_record_key(&"€".repeat(86)).is_err());
assert!(validate_record_key(&"€".repeat(85)).is_ok());
}
#[test]
fn plugin_ids_are_reverse_dns_and_topic_safe() {
for good in ["com.example.a", "a.b", "com.example.multi-word", "x9.y-z.q"] {
assert!(validate_plugin_id(good).is_ok(), "{good}");
}
for bad in [
"",
"single",
"com.",
".com.x",
"com..x",
"Com.example.a",
"com-x.example",
"com.example/a",
"com.example.+",
"com.example.#",
] {
assert!(validate_plugin_id(bad).is_err(), "{bad}");
}
let long = format!("com.{}", "a".repeat(MAX_PLUGIN_ID_LEN));
assert!(validate_plugin_id(&long).is_err());
}
#[test]
fn subject_device_ids_are_bounded_tokens() {
assert!(validate_device_id("a1b2c3d4e5f6").is_ok());
for bad in ["", "a/b", "a b", &"d".repeat(MAX_DEVICE_ID_LEN + 1)] {
assert!(validate_device_id(bad).is_err(), "{bad}");
}
}
#[tokio::test]
async fn send_writes_one_frame_and_returns_the_relays_reply() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CLOUD_PUBLISH_SOCK_NAME);
let listener = UnixListener::bind(&path).unwrap();
let server = tokio::spawn(async move {
let (mut s, _) = listener.accept().await.unwrap();
let body = crate::ipc::read_length_prefixed(&mut s, MAX_FRAME, true)
.await
.unwrap()
.unwrap();
let req = CloudPublishRequest::decode(&body).unwrap();
let reply = CloudPublishReply::refused(format!("saw {}", req.plugin_id));
s.write_all(&reply.encode().unwrap()).await.unwrap();
let mut rest = Vec::new();
s.read_to_end(&mut rest).await.unwrap();
assert!(rest.is_empty());
});
let reply = send(
&path,
&CloudPublishRequest::stream("com.example.a", "s", b"x".to_vec()),
)
.await
.unwrap();
assert_eq!(reply, CloudPublishReply::refused("saw com.example.a"));
server.await.unwrap();
}
#[tokio::test]
async fn send_refuses_an_invalid_request_without_connecting() {
let dir = tempfile::tempdir().unwrap();
let req = CloudPublishRequest::stream("com.example.a", "Bad Name", vec![]);
assert!(matches!(
send(&dir.path().join("absent.sock"), &req).await,
Err(CloudPublishError::Invalid(_))
));
}
#[tokio::test]
async fn send_reports_an_absent_socket_as_io() {
let dir = tempfile::tempdir().unwrap();
let req = CloudPublishRequest::stream("com.example.a", "s", vec![]);
assert!(matches!(
send(&dir.path().join("absent.sock"), &req).await,
Err(CloudPublishError::Io(_))
));
}
}