Skip to main content

cufflink_types/
ext.rs

1//! Extensions: the names, the NATS subjects and the messages of an
2//! `ext_call`, shared by the platform, the worker, the extension runner,
3//! the native services and the guest SDK.
4
5use crate::host::ext::{CallErrorEnvelope, CallErrorKind};
6use crate::slug::is_valid_service_name;
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::BTreeSet;
10use uuid::Uuid;
11
12/// The longest extension name, in bytes.
13pub const NAME_MAX: usize = 128;
14
15/// The subject every call subject starts with.
16pub const SUBJECT_PREFIX: &str = "cufflink.ext";
17
18/// The queue group every provider of an extension subscribes in, so each
19/// call reaches one of them.
20pub const QUEUE_GROUP: &str = "cufflink-ext";
21
22/// The version of [`ExtRequest`] and [`Ready`] this crate speaks.
23pub const PROTOCOL_VERSION: u32 = 1;
24
25/// A caller that stands for every service of the tenant.
26pub const ANY_CALLER: &str = "*";
27
28/// The variable naming the Unix socket a native service serves on.
29pub const SOCKET_VAR: &str = "CUFFLINK_EXT_SOCKET";
30
31/// The variable holding the token the extension runner sends a native
32/// service as `Authorization: Bearer <token>` with every request; each
33/// start of a child has its own.
34pub const TOKEN_VAR: &str = "CUFFLINK_EXT_TOKEN";
35
36/// Whether `name` is an extension name: 1 to [`NAME_MAX`] bytes of
37/// dot-separated segments of `[a-z0-9-]+` (`google.token`, `pdf.render-v2`).
38pub fn is_valid_extension_name(name: &str) -> bool {
39    name.len() <= NAME_MAX
40        && name.split('.').all(|segment| {
41            !segment.is_empty()
42                && segment
43                    .bytes()
44                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
45        })
46}
47
48/// Why `name` is not an extension name; `None` when it is one.
49pub fn name_violation(name: &str) -> Option<String> {
50    (!is_valid_extension_name(name)).then(|| {
51        format!(
52            "'{name}' is not an extension name (segments of a-z, 0-9 and '-' joined by '.', at \
53             most {NAME_MAX} bytes)"
54        )
55    })
56}
57
58/// The callers of an extension as they are stored: each a service name or
59/// [`ANY_CALLER`], at least one, each once, sorted.
60pub fn callers(requested: Vec<String>) -> Result<Vec<String>, String> {
61    match requested
62        .iter()
63        .find(|caller| caller.as_str() != ANY_CALLER && !is_valid_service_name(caller))
64    {
65        Some(invalid) => Err(format!(
66            "callers: '{invalid}' is neither a service name nor '{ANY_CALLER}'"
67        )),
68        None if requested.is_empty() => Err("callers: at least one caller is required".into()),
69        None => Ok(requested
70            .into_iter()
71            .collect::<BTreeSet<_>>()
72            .into_iter()
73            .collect()),
74    }
75}
76
77/// The subject the calls of the tenant `tenant` to the extension `name`
78/// travel on.
79pub fn call_subject(tenant: &str, name: &str) -> String {
80    format!("{SUBJECT_PREFIX}.{tenant}.{name}")
81}
82
83/// The subject a deploy or rollback of the tenant's native service
84/// `service` is announced on, for the extension runner.
85pub fn deploy_subject(tenant: &str, service: &str) -> String {
86    format!("deploy.ext.{tenant}.{service}")
87}
88
89/// The subject the removal of the tenant's native service `service` is
90/// announced on.
91pub fn undeploy_subject(tenant: &str, service: &str) -> String {
92    format!("undeploy.ext.{tenant}.{service}")
93}
94
95/// A call as its provider receives it.
96#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct ExtRequest {
98    /// [`PROTOCOL_VERSION`] of the sender.
99    pub v: u32,
100    pub call_id: Uuid,
101    pub extension: String,
102    pub context: CallContext,
103    pub payload: Value,
104}
105
106impl ExtRequest {
107    /// Whether the call is `tenant`'s and came on `subject`, its
108    /// extension's: a call relayed on another subject is refused.
109    pub fn matches(&self, tenant: &str, subject: &str) -> bool {
110        self.context.tenant == tenant && subject == call_subject(tenant, &self.extension)
111    }
112}
113
114/// Who calls, and until when the caller waits.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct CallContext {
117    pub tenant: String,
118    pub service: CallerService,
119    /// The verified claims of the caller's principal, as the calling
120    /// handler sees them; never a token.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub auth: Option<Value>,
123    /// Milliseconds since the Unix epoch after which nobody waits for the
124    /// reply.
125    pub deadline_ms: u64,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129pub struct CallerService {
130    pub name: String,
131    pub version: i32,
132}
133
134/// A provider's answer: `{"ok": <value>}` or
135/// `{"error": {"code": …, "message": …}}`.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138pub enum ExtReply {
139    Ok(Value),
140    Error(ExtFailure),
141}
142
143impl ExtReply {
144    /// The reply that fails a call with `code`.
145    pub fn failure(code: &str, message: impl Into<String>) -> Self {
146        Self::Error(ExtFailure {
147            code: code.into(),
148            message: message.into(),
149        })
150    }
151}
152
153/// Why an extension refused or failed a call, in its own words.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct ExtFailure {
156    pub code: String,
157    pub message: String,
158}
159
160/// The codes a runner or a provider answers for the transport rather than
161/// for the extension: each is the label of the kind the caller sees.
162pub const RESERVED_CODES: [CallErrorKind; 3] = [
163    CallErrorKind::Unavailable,
164    CallErrorKind::Timeout,
165    CallErrorKind::TooLarge,
166];
167
168impl ExtFailure {
169    /// What the caller sees of the failure: the kind a reserved code
170    /// names, and [`CallErrorKind::Failed`] for every other code.
171    pub fn kind(&self) -> CallErrorKind {
172        RESERVED_CODES
173            .into_iter()
174            .find(|kind| kind.as_str() == self.code)
175            .unwrap_or(CallErrorKind::Failed)
176    }
177}
178
179impl From<ExtFailure> for CallErrorEnvelope {
180    fn from(failure: ExtFailure) -> Self {
181        let kind = failure.kind();
182        Self {
183            kind,
184            code: (kind == CallErrorKind::Failed).then_some(failure.code),
185            message: failure.message,
186        }
187    }
188}
189
190/// What a native service answers once it serves its extensions.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct Ready {
193    pub protocol: u32,
194    pub extensions: Vec<String>,
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::host::tests::{refused, round_trip};
201    use serde_json::json;
202
203    #[test]
204    fn extension_names() {
205        let longest = ["a".repeat(63), "b".repeat(64)].join(".");
206        assert_eq!(longest.len(), NAME_MAX);
207        for valid in [
208            "google.token",
209            "pdf.render-v2",
210            "a",
211            "0",
212            "-",
213            "a.b.c.d",
214            "x-1.y-2",
215            &"a".repeat(NAME_MAX),
216            &longest,
217        ] {
218            assert!(is_valid_extension_name(valid), "{valid}");
219        }
220        for invalid in [
221            "",
222            ".",
223            ".google",
224            "google.",
225            "google..token",
226            "Google.token",
227            "google.Token",
228            "google.*",
229            "google.>",
230            "*",
231            ">",
232            "google token",
233            " google",
234            "google_token",
235            "google/token",
236            "göögle",
237            "google.token\n",
238            &"a".repeat(NAME_MAX + 1),
239            &format!("{longest}a"),
240        ] {
241            assert!(!is_valid_extension_name(invalid), "{invalid:?}");
242        }
243    }
244
245    #[test]
246    fn callers_are_services_or_any_each_once() {
247        let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::<Vec<_>>();
248        assert_eq!(
249            callers(names(&["reports", "billing", "reports"])),
250            Ok(names(&["billing", "reports"]))
251        );
252        assert_eq!(callers(names(&["*", "*"])), Ok(names(&["*"])));
253        assert_eq!(
254            callers(vec![]),
255            Err("callers: at least one caller is required".into())
256        );
257        for invalid in ["", "a b", "a.b", "**", "reports/*"] {
258            assert_eq!(
259                callers(names(&["reports", invalid])),
260                Err(format!(
261                    "callers: '{invalid}' is neither a service name nor '*'"
262                )),
263                "{invalid:?}"
264            );
265        }
266    }
267
268    #[test]
269    fn a_call_travels_on_the_subject_of_its_tenant_and_extension() {
270        assert_eq!(
271            call_subject("mantis", "google.token"),
272            "cufflink.ext.mantis.google.token"
273        );
274        assert_eq!(
275            deploy_subject("mantis", "ext-google"),
276            "deploy.ext.mantis.ext-google"
277        );
278        assert_eq!(
279            undeploy_subject("mantis", "ext-google"),
280            "undeploy.ext.mantis.ext-google"
281        );
282    }
283
284    #[test]
285    fn a_call_matches_only_its_tenant_and_the_subject_of_its_extension() {
286        let request = ExtRequest {
287            v: PROTOCOL_VERSION,
288            call_id: Uuid::nil(),
289            extension: "google.token".into(),
290            context: CallContext {
291                tenant: "mantis".into(),
292                service: CallerService {
293                    name: "reports".into(),
294                    version: 1,
295                },
296                auth: None,
297                deadline_ms: 0,
298            },
299            payload: Value::Null,
300        };
301        assert!(request.matches("mantis", "cufflink.ext.mantis.google.token"));
302        for (tenant, subject) in [
303            ("mantis", "cufflink.ext.mantis.google.other"),
304            ("mantis", "cufflink.ext.globex.google.token"),
305            ("globex", "cufflink.ext.globex.google.token"),
306            ("globex", "cufflink.ext.mantis.google.token"),
307        ] {
308            assert!(!request.matches(tenant, subject), "{tenant} {subject}");
309        }
310    }
311
312    #[test]
313    fn a_request_round_trips() {
314        let context = CallContext {
315            tenant: "mantis".into(),
316            service: CallerService {
317                name: "reports-service".into(),
318                version: 7,
319            },
320            auth: Some(json!({"sub": "kc-1", "realm_access": {"roles": ["staff"]}})),
321            deadline_ms: 1_790_589_600_000,
322        };
323        round_trip(
324            &ExtRequest {
325                v: PROTOCOL_VERSION,
326                call_id: Uuid::from_u128(0x0123_4567_89ab_4def_8123_4567_89ab_cdef),
327                extension: "google.token".into(),
328                context: context.clone(),
329                payload: json!({"scope": "analytics.readonly"}),
330            },
331            json!({
332                "v": 1,
333                "call_id": "01234567-89ab-4def-8123-456789abcdef",
334                "extension": "google.token",
335                "context": {
336                    "tenant": "mantis",
337                    "service": {"name": "reports-service", "version": 7},
338                    "auth": {"sub": "kc-1", "realm_access": {"roles": ["staff"]}},
339                    "deadline_ms": 1_790_589_600_000u64
340                },
341                "payload": {"scope": "analytics.readonly"}
342            }),
343        );
344        round_trip(
345            &CallContext {
346                auth: None,
347                ..context
348            },
349            json!({
350                "tenant": "mantis",
351                "service": {"name": "reports-service", "version": 7},
352                "deadline_ms": 1_790_589_600_000u64
353            }),
354        );
355        refused::<ExtRequest>(json!({
356            "v": 1,
357            "call_id": "not a uuid",
358            "extension": "a",
359            "context": {"tenant": "t", "service": {"name": "s", "version": 1}, "deadline_ms": 0},
360            "payload": null
361        }));
362    }
363
364    #[test]
365    fn a_reply_is_keyed_by_its_outcome() {
366        round_trip(
367            &ExtReply::Ok(json!({"token": "ya29"})),
368            json!({"ok": {"token": "ya29"}}),
369        );
370        round_trip(&ExtReply::Ok(Value::Null), json!({"ok": null}));
371        round_trip(
372            &ExtReply::Error(ExtFailure {
373                code: "quota".into(),
374                message: "daily quota exhausted".into(),
375            }),
376            json!({"error": {"code": "quota", "message": "daily quota exhausted"}}),
377        );
378    }
379
380    #[test]
381    fn a_reply_with_both_outcomes_or_neither_is_refused() {
382        for wire in [
383            json!({"ok": 1, "error": {"code": "c", "message": "m"}}),
384            json!({"error": {"code": "c", "message": "m"}, "ok": 1}),
385            json!({}),
386            json!("ok"),
387            json!({"error": {"code": "c"}}),
388            json!({"done": 1}),
389            json!(null),
390        ] {
391            refused::<ExtReply>(wire.clone());
392            let parsed = serde_json::from_str::<ExtReply>(&wire.to_string());
393            assert!(parsed.is_err(), "{wire} parsed as {parsed:?}");
394        }
395    }
396
397    #[test]
398    fn only_the_reserved_codes_name_a_kind_of_their_own() {
399        let failure = |code: &str| ExtFailure {
400            code: code.into(),
401            message: "why".into(),
402        };
403        for (code, kind) in [
404            ("unavailable", CallErrorKind::Unavailable),
405            ("timeout", CallErrorKind::Timeout),
406            ("too_large", CallErrorKind::TooLarge),
407            ("failed", CallErrorKind::Failed),
408            ("quota", CallErrorKind::Failed),
409            ("not_found", CallErrorKind::Failed),
410            ("invalid", CallErrorKind::Failed),
411            ("denied", CallErrorKind::Failed),
412            ("Timeout", CallErrorKind::Failed),
413            ("", CallErrorKind::Failed),
414        ] {
415            assert_eq!(failure(code).kind(), kind, "{code:?}");
416        }
417        assert_eq!(
418            CallErrorEnvelope::from(failure("too_large")),
419            CallErrorEnvelope {
420                kind: CallErrorKind::TooLarge,
421                code: None,
422                message: "why".into(),
423            }
424        );
425        assert_eq!(
426            CallErrorEnvelope::from(failure("quota")),
427            CallErrorEnvelope {
428                kind: CallErrorKind::Failed,
429                code: Some("quota".into()),
430                message: "why".into(),
431            }
432        );
433        assert_eq!(
434            ExtReply::failure("quota", "why"),
435            ExtReply::Error(failure("quota"))
436        );
437    }
438
439    #[test]
440    fn a_name_that_is_not_an_extension_name_says_what_one_is() {
441        assert_eq!(name_violation("google.token"), None);
442        assert_eq!(
443            name_violation("Google"),
444            Some(
445                "'Google' is not an extension name (segments of a-z, 0-9 and '-' joined by '.', \
446                 at most 128 bytes)"
447                    .into()
448            )
449        );
450    }
451
452    #[test]
453    fn readiness_round_trips() {
454        round_trip(
455            &Ready {
456                protocol: PROTOCOL_VERSION,
457                extensions: vec!["google.token".into(), "pdf.render".into()],
458            },
459            json!({"protocol": 1, "extensions": ["google.token", "pdf.render"]}),
460        );
461    }
462}