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;
10use uuid::Uuid;
11
12pub const NAME_MAX: usize = 128;
14
15pub const SUBJECT_PREFIX: &str = "cufflink.ext";
17
18pub const QUEUE_GROUP: &str = "cufflink-ext";
21
22pub const PROTOCOL_VERSION: u32 = 1;
24
25pub const ANY_CALLER: &str = "*";
27
28pub const SOCKET_VAR: &str = "CUFFLINK_EXT_SOCKET";
30
31pub const TOKEN_VAR: &str = "CUFFLINK_EXT_TOKEN";
35
36pub 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
48pub 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
58pub 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
77pub fn call_subject(tenant: &str, name: &str) -> String {
80 format!("{SUBJECT_PREFIX}.{tenant}.{name}")
81}
82
83pub fn deploy_subject(tenant: &str, service: &str) -> String {
86 format!("deploy.ext.{tenant}.{service}")
87}
88
89pub fn undeploy_subject(tenant: &str, service: &str) -> String {
92 format!("undeploy.ext.{tenant}.{service}")
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
97pub struct ExtRequest {
98 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 pub fn matches(&self, tenant: &str, subject: &str) -> bool {
110 self.context.tenant == tenant && subject == call_subject(tenant, &self.extension)
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct CallContext {
117 pub tenant: String,
118 pub service: CallerService,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub auth: Option<Value>,
123 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#[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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct ExtFailure {
156 pub code: String,
157 pub message: String,
158}
159
160pub const RESERVED_CODES: [CallErrorKind; 3] = [
163 CallErrorKind::Unavailable,
164 CallErrorKind::Timeout,
165 CallErrorKind::TooLarge,
166];
167
168impl ExtFailure {
169 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#[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}