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