1use 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
13pub const NAME_MAX: usize = 128;
15
16pub const SUBJECT_PREFIX: &str = "cufflink.ext";
18
19pub const QUEUE_GROUP: &str = "cufflink-ext";
22
23pub const PROTOCOL_VERSION: u32 = 1;
25
26pub const ANY_CALLER: &str = "*";
28
29pub const SOCKET_VAR: &str = "CUFFLINK_EXT_SOCKET";
31
32pub const TOKEN_VAR: &str = "CUFFLINK_EXT_TOKEN";
36
37pub 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
49pub 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
59pub 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
78pub fn call_subject(tenant: &str, name: &str) -> String {
81 format!("{SUBJECT_PREFIX}.{tenant}.{name}")
82}
83
84pub fn deploy_subject(tenant: &str, service: &str) -> String {
87 format!("deploy.ext.{tenant}.{service}")
88}
89
90pub fn undeploy_subject(tenant: &str, service: &str) -> String {
93 format!("undeploy.ext.{tenant}.{service}")
94}
95
96#[cfg(feature = "protocol")]
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
99pub struct ExtRequest {
100 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 pub fn matches(&self, tenant: &str, subject: &str) -> bool {
113 self.context.tenant == tenant && subject == call_subject(tenant, &self.extension)
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119pub struct CallContext {
120 pub tenant: String,
121 pub service: CallerService,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub auth: Option<Value>,
126 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#[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct ExtFailure {
159 pub code: String,
160 pub message: String,
161}
162
163pub const RESERVED_CODES: [CallErrorKind; 3] = [
166 CallErrorKind::Unavailable,
167 CallErrorKind::Timeout,
168 CallErrorKind::TooLarge,
169];
170
171impl ExtFailure {
172 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#[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("mantis", "google.token"),
275 "cufflink.ext.mantis.google.token"
276 );
277 assert_eq!(
278 deploy_subject("mantis", "ext-google"),
279 "deploy.ext.mantis.ext-google"
280 );
281 assert_eq!(
282 undeploy_subject("mantis", "ext-google"),
283 "undeploy.ext.mantis.ext-google"
284 );
285 }
286
287 fn context() -> CallContext {
288 CallContext {
289 tenant: "mantis".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("mantis", "cufflink.ext.mantis.google.token"));
310 for (tenant, subject) in [
311 ("mantis", "cufflink.ext.mantis.google.other"),
312 ("mantis", "cufflink.ext.globex.google.token"),
313 ("globex", "cufflink.ext.globex.google.token"),
314 ("globex", "cufflink.ext.mantis.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": "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 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": "mantis",
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}