Skip to main content

ados_protocol/
cloud_publish.rs

1//! The cloud-publish socket: how a plugin's agent half sends data off the node
2//! through the cloud relay.
3//!
4//! The plugin host holds the capability gate (`cloud.publish`, `cloud.records`)
5//! and forwards an admitted call here; `ados-cloud`, which owns the broker
6//! session and the device's cloud credentials, serves the socket and does the
7//! actual publish. A plugin never reaches this socket itself: it is root-only
8//! (`0o600`) and hidden from the plugin sandbox with the rest of the run dir.
9//!
10//! ## Wire
11//!
12//! A request is one [`crate::frame`] frame (4-byte big-endian length, then the
13//! body; zero-length rejected, at most [`MAX_FRAME`] bytes) whose body is a
14//! msgpack map with string keys:
15//!
16//! ```text
17//! { v: 1, kind: "stream" | "record", plugin_id: str,
18//!   stream?: str, collection?: str, key?: str, device_id?: str,
19//!   payload: bin }
20//! ```
21//!
22//! The reply is one frame of the same framing, a msgpack map
23//! `{ ok: bool, error?: str }`. A connection may carry several request/reply
24//! exchanges in sequence; [`send`] uses one connection per request.
25//!
26//! * `kind: "stream"` carries `stream` and no `collection`/`key`/`device_id`.
27//!   The relay publishes the payload at QoS 0 on
28//!   `ados/{device_id}/plugin/{plugin_id}/{stream}` under the node's own device
29//!   id. The one exception is [`VISION_DETECTIONS_STREAM`]: its payload must be
30//!   a JSON `vision.detection` batch, and it goes to the core topic
31//!   `ados/{device_id}/vision/detections` the ground station already reads.
32//!   `ok` means the message entered the relay's bounded publish queue. The lane
33//!   is lossy by design, so it does not mean the broker received it.
34//! * `kind: "record"` carries `collection` and `key` (and optionally a subject
35//!   `device_id`, defaulting to this node). The payload is the record's JSON.
36//!   The relay upserts it into the account's plugin records, and `ok` is the
37//!   cloud's own answer to that write.
38//!
39//! Names are constrained so they are safe as topic segments and as record
40//! identifiers: see [`validate_stream_name`], [`validate_collection`],
41//! [`validate_record_key`] and [`validate_plugin_id`].
42
43use 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
53/// The contract's wire version, stamped on every request and checked on decode.
54/// Mirrors the `cloud-publish` entry in the contract registry.
55pub const CLOUD_PUBLISH_VERSION: u16 = 1;
56
57/// Socket file name under the agent run directory.
58pub const CLOUD_PUBLISH_SOCK_NAME: &str = "cloud-publish.sock";
59
60/// Default agent run directory, overridden by `ADOS_RUN_DIR`.
61const DEFAULT_RUN_DIR: &str = "/run/ados";
62
63/// The socket's file mode: owner (root) only. Only the plugin host, which runs
64/// as root and has already applied the capability gate, may publish.
65pub const SOCKET_MODE: u32 = 0o600;
66
67/// Largest frame body either side accepts.
68pub const MAX_FRAME: usize = 80 * 1024;
69
70/// Largest payload a request may carry.
71pub const MAX_PAYLOAD: usize = 64 * 1024;
72
73/// Longest record key, in UTF-8 bytes.
74pub const MAX_RECORD_KEY_BYTES: usize = 256;
75
76/// Longest plugin id.
77pub const MAX_PLUGIN_ID_LEN: usize = 128;
78
79/// Longest subject device id on a record.
80pub const MAX_DEVICE_ID_LEN: usize = 64;
81
82/// The stream name that maps to the core vision-detection topic instead of the
83/// plugin's own subtree. The plugin host admits it only for a plugin that also
84/// holds `vision.detection.publish`.
85pub const VISION_DETECTIONS_STREAM: &str = "vision.detections";
86
87/// Bound on a stream exchange. The relay answers as soon as the message is
88/// queued, so anything slower is a stall.
89pub const STREAM_REPLY_TIMEOUT: Duration = Duration::from_secs(2);
90
91/// Bound on a record exchange. The relay answers only once the cloud has
92/// answered the write, and its own HTTP client gives up after 10 s.
93pub const RECORD_REPLY_TIMEOUT: Duration = Duration::from_secs(15);
94
95/// The socket path, honouring the `ADOS_RUN_DIR` override so a test or a
96/// development host does not collide with the running service.
97pub 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/// What a request asks the relay to do.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum CloudPublishKind {
108    /// A lossy live message on the broker (QoS 0).
109    Stream,
110    /// A keyed JSON record upserted into the account's plugin records.
111    Record,
112}
113
114impl CloudPublishKind {
115    /// How long [`send`] waits for the relay's reply to this kind of request.
116    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/// One request on the socket.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct CloudPublishRequest {
127    pub v: u16,
128    pub kind: CloudPublishKind,
129    /// The publishing plugin, bound by the plugin host from the caller's
130    /// identity, never taken from plugin input.
131    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    /// The record's subject device; `None` means this node.
139    #[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    /// A stream request.
147    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    /// A record request. `payload` is the record's JSON encoding.
165    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    /// Name the record's subject device (a record only).
184    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    /// Check the request against the contract: version, plugin id, the fields
190    /// its kind requires and forbids, every name's grammar, and the payload cap.
191    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    /// Validate, then encode as one length-prefixed frame.
241    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    /// Decode a frame body (length prefix already consumed) and validate it.
249    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/// The relay's answer to one request.
258#[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    /// The request was accepted.
267    pub fn accepted() -> Self {
268        Self {
269            ok: true,
270            error: None,
271        }
272    }
273
274    /// The request was refused, with the reason.
275    pub fn refused(reason: impl Into<String>) -> Self {
276        Self {
277            ok: false,
278            error: Some(reason.into()),
279        }
280    }
281
282    /// Encode as one length-prefixed frame.
283    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    /// Decode a frame body (length prefix already consumed).
290    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/// Why a request did not get a reply, or was refused before it was sent.
296#[derive(Debug, Clone, PartialEq, Eq, Error)]
297pub enum CloudPublishError {
298    /// The request breaks the contract; nothing was sent.
299    #[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    /// The socket could not be reached, or the exchange failed mid-way.
306    #[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
325/// Send one request and wait for the relay's reply, bounded by the kind's
326/// [`reply_timeout`](CloudPublishKind::reply_timeout). An invalid request is
327/// refused before anything is connected. `Ok` carries the relay's answer,
328/// which may itself be a refusal (`ok: false`).
329pub 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
350/// A plugin id: reverse-DNS lowercase (`^[a-z0-9]+(\.[a-z0-9-]+)+$`, the
351/// manifest rule), at most [`MAX_PLUGIN_ID_LEN`] characters. It becomes a topic
352/// segment, so it can never carry `/`, `+` or `#`.
353pub 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
371/// A stream name: `[a-z0-9][a-z0-9._-]{0,63}`.
372pub 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
388/// A record collection: `[a-z0-9_.-]{1,64}`.
389pub 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
403/// A record key: non-empty, at most [`MAX_RECORD_KEY_BYTES`] UTF-8 bytes.
404pub 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
415/// A record's subject device id: `[A-Za-z0-9._-]{1,64}`.
416pub 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
434/// The payload travels as msgpack `bin`, not as an array of integers.
435mod 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        // The payload is msgpack bin and the map keys are the contract's names,
492        // so a reader in any language decodes it without this crate.
493        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        // An accepted reply carries no error key at all.
540        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        // 86 three-byte characters are 258 bytes: over the cap at 86 characters.
642        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            // The client reads exactly one reply frame; nothing else follows.
692            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        // No socket exists at this path, so an attempted connect would surface
710        // as Io rather than Invalid.
711        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}