1use std::fmt;
36
37use serde::{Deserialize, Deserializer, Serialize, Serializer};
38
39use crate::errors::RuntimeError;
40
41pub const FORMATION_ID_ENV: &str = "MURMUR_FORMATION_ID";
44
45pub const FORMATION_ID_PREFIX: &str = "frm_";
48
49const FORMATION_ID_DIGITS: usize = 32;
51
52#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub struct FormationId(String);
55
56impl FormationId {
57 pub fn mint() -> Self {
61 Self(format!(
62 "{FORMATION_ID_PREFIX}{}",
63 uuid::Uuid::now_v7().simple()
64 ))
65 }
66
67 pub fn parse(value: &str) -> Result<Self, RuntimeError> {
71 let unreadable = |reason: &str| RuntimeError::FormationIdUnreadable {
72 reason: reason.to_string(),
73 };
74 let Some(digits) = value.strip_prefix(FORMATION_ID_PREFIX) else {
75 return Err(unreadable("it does not start with 'frm_'"));
76 };
77 if digits.len() != FORMATION_ID_DIGITS {
78 return Err(unreadable(
79 "'frm_' must be followed by exactly 32 hex digits",
80 ));
81 }
82 if !digits
83 .bytes()
84 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
85 {
86 return Err(unreadable(
87 "it contains a character outside lowercase hex (0-9, a-f)",
88 ));
89 }
90 Ok(Self(value.to_string()))
91 }
92
93 pub fn from_env() -> Result<Option<Self>, RuntimeError> {
100 let Some(raw) = std::env::var_os(FORMATION_ID_ENV) else {
101 return Ok(None);
102 };
103 let raw = raw.to_string_lossy();
104 if raw.trim().is_empty() {
105 return Ok(None);
106 }
107 Self::parse(raw.trim()).map(Some)
108 }
109
110 pub fn as_str(&self) -> &str {
111 &self.0
112 }
113
114 pub fn minted_at_ms(&self) -> u64 {
120 crate::retention::uuid_v7_timestamp_ms(&self.0, FORMATION_ID_PREFIX).unwrap_or(0)
122 }
123
124 pub fn env_pair(&self) -> (&'static str, String) {
126 (FORMATION_ID_ENV, self.0.clone())
127 }
128}
129
130impl fmt::Display for FormationId {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 f.write_str(&self.0)
133 }
134}
135
136impl Serialize for FormationId {
138 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
139 serializer.serialize_str(self.as_str())
140 }
141}
142
143impl<'de> Deserialize<'de> for FormationId {
146 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
147 let value = String::deserialize(deserializer)?;
148 Self::parse(&value).map_err(|error| match error {
149 RuntimeError::FormationIdUnreadable { reason } => {
150 serde::de::Error::custom(format!("not a formation id: {reason}"))
151 }
152 _ => serde::de::Error::custom("not a formation id"),
153 })
154 }
155}
156
157pub const FORMATION_PEERS_ENV: &str = "MURMUR_FORMATION_PEERS";
163
164const PEER_URL_SCHEME: &str = "http://";
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct FormationPeer {
170 pub name: String,
172 pub url: String,
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct FormationPeers(Vec<FormationPeer>);
185
186impl FormationPeers {
187 pub fn new(peers: Vec<FormationPeer>) -> Result<Self, RuntimeError> {
190 if peers.is_empty() {
191 return Err(peers_unreadable(
192 "it names no peer; a member that may call nobody is handed no variable at all",
193 ));
194 }
195 let mut seen = std::collections::HashSet::new();
196 for (index, peer) in peers.iter().enumerate() {
197 let position = index + 1;
198 if let Some(problem) = murmur_artifact::member_name_format_error(&peer.name) {
199 return Err(peers_unreadable(&format!(
200 "pair {position} names a member whose name {problem}"
201 )));
202 }
203 if !seen.insert(peer.name.as_str()) {
204 return Err(peers_unreadable(&format!(
205 "pair {position} names '{}' a second time",
206 peer.name
207 )));
208 }
209 if let Some(problem) = url_format_error(&peer.url, false) {
210 return Err(peers_unreadable(&format!(
211 "pair {position} ('{}') {problem}",
212 peer.name
213 )));
214 }
215 }
216 Ok(Self(peers))
217 }
218
219 pub fn render(&self) -> String {
221 self.0
222 .iter()
223 .map(|peer| format!("{}={}", peer.name, peer.url))
224 .collect::<Vec<_>>()
225 .join(" ")
226 }
227
228 pub fn env_pair(&self) -> (&'static str, String) {
231 (FORMATION_PEERS_ENV, self.render())
232 }
233
234 pub fn peers(&self) -> &[FormationPeer] {
235 &self.0
236 }
237}
238
239pub(crate) fn is_member_grant_env(name: &str) -> bool {
245 name == FORMATION_PEERS_ENV
246 || name == crate::formation_credentials::FORMATION_CHANNEL_ENV
247 || name == crate::lifeline::FORMATION_LIFELINE_ENV
248}
249
250pub fn refuse_formation_peers_in_process_env() -> Result<(), RuntimeError> {
257 if std::env::var_os(FORMATION_PEERS_ENV).is_some() {
258 return Err(peers_unreadable("it is set in this process's environment"));
259 }
260 Ok(())
261}
262
263fn peers_unreadable(reason: &str) -> RuntimeError {
264 RuntimeError::FormationPeersUnreadable {
265 reason: reason.to_string(),
266 }
267}
268
269pub(crate) fn peer_url_format_error(url: &str) -> Option<String> {
271 url_format_error(url, true)
272}
273
274fn url_format_error(url: &str, port_required: bool) -> Option<String> {
277 let Some(authority) = url.strip_prefix(PEER_URL_SCHEME) else {
278 return Some("has a URL that does not start with 'http://'".to_string());
279 };
280 let (host, port) = match authority.rsplit_once(':') {
282 Some((host, port)) if !authority.ends_with(']') => (host, Some(port)),
283 _ => (authority, None),
284 };
285 if port.is_none() && port_required {
286 return Some("has a URL with no port".to_string());
287 }
288 let bracketed_ip = host.len() > 2 && host.starts_with('[') && host.ends_with(']');
289 let plain_host = !host.is_empty()
290 && host
291 .bytes()
292 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-'));
293 if !(bracketed_ip || plain_host) {
294 return Some("has a URL whose host is not a hostname or an IP address".to_string());
295 }
296 let port = port?;
297 let digits_only = !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit());
298 match port.parse::<u16>() {
299 Ok(port) if digits_only && port > 0 => None,
300 _ => Some("has a URL whose port is not a number from 1 to 65535".to_string()),
301 }
302}
303
304#[cfg(test)]
307pub(crate) static FORMATION_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn env_guard() -> std::sync::MutexGuard<'static, ()> {
314 FORMATION_ENV_LOCK
315 .lock()
316 .unwrap_or_else(|poisoned| poisoned.into_inner())
317 }
318
319 fn refusal(value: &str) -> String {
320 match FormationId::parse(value) {
321 Err(error @ RuntimeError::FormationIdUnreadable { .. }) => error.to_string(),
322 other => panic!("expected FormationIdUnreadable, got {other:?}"),
323 }
324 }
325
326 #[test]
327 fn a_minted_formation_id_is_well_formed() {
328 let id = FormationId::mint();
329 assert_eq!(id.as_str().len(), 36);
330 assert!(id.as_str().starts_with(FORMATION_ID_PREFIX));
331 assert_eq!(FormationId::parse(id.as_str()).unwrap(), id);
332 assert_eq!(id.to_string(), id.as_str());
333 assert_eq!(id.env_pair(), (FORMATION_ID_ENV, id.as_str().to_string()));
334 }
335
336 #[test]
337 fn minted_formation_ids_are_distinct_and_sort_in_mint_order() {
338 let minted: Vec<FormationId> = (0..1_000).map(|_| FormationId::mint()).collect();
339 let distinct: std::collections::HashSet<&FormationId> = minted.iter().collect();
340 assert_eq!(distinct.len(), minted.len());
341 let mut sorted: Vec<&str> = minted.iter().map(FormationId::as_str).collect();
342 sorted.sort_unstable();
343 let in_mint_order: Vec<&str> = minted.iter().map(FormationId::as_str).collect();
344 assert_eq!(sorted, in_mint_order);
345 }
346
347 #[test]
348 fn a_formation_id_parse_refuses_near_misses_without_echoing_them() {
349 let minted = FormationId::mint();
350 let digits = &minted.as_str()[FORMATION_ID_PREFIX.len()..];
351 let near_misses = [
352 "frm_".to_string(),
353 format!("frm_{}", &digits[..31]),
354 format!("frm_{digits}0"),
355 format!("frm_{}", digits.to_uppercase()),
356 format!("frm_{}A", &digits[..31]),
357 format!("dlg_{digits}"),
358 format!("ses_{digits}"),
359 format!(" {}", minted.as_str()),
360 format!("{} ", minted.as_str()),
361 format!("x{}", minted.as_str()),
362 format!("{}x", minted.as_str()),
363 format!("frm_{}g", &digits[..31]),
364 format!("frm_{}-", &digits[..31]),
365 "not-a-formation".to_string(),
366 ];
367 for value in &near_misses {
368 let message = refusal(value);
369 if value != FORMATION_ID_PREFIX {
371 assert!(
372 !message.contains(value.trim()),
373 "the refusal echoes the value {value:?}: {message}"
374 );
375 }
376 assert!(!message.contains(&digits[..16]), "{message}");
377 assert!(!message.contains(&digits[..16].to_uppercase()), "{message}");
378 assert!(message.contains("MURMUR_FORMATION_ID"), "{message}");
379 }
380 assert!(refusal("dlg_x").contains("does not start with 'frm_'"));
381 assert!(refusal(&format!("frm_{}", &digits[..31])).contains("exactly 32 hex digits"));
382 assert!(refusal(&format!("frm_{}", digits.to_uppercase())).contains("lowercase hex"));
383 assert!(refusal(&format!("frm_{}g", &digits[..31])).contains("lowercase hex"));
384 }
385
386 #[test]
387 fn a_formation_id_is_read_from_the_process_environment_only_when_well_formed() {
388 let _guard = env_guard();
389 std::env::remove_var(FORMATION_ID_ENV);
390 assert_eq!(FormationId::from_env().unwrap(), None);
391
392 std::env::set_var(FORMATION_ID_ENV, " ");
393 assert_eq!(FormationId::from_env().unwrap(), None);
394
395 let minted = FormationId::mint();
396 std::env::set_var(FORMATION_ID_ENV, minted.as_str());
397 assert_eq!(FormationId::from_env().unwrap(), Some(minted.clone()));
398
399 std::env::set_var(FORMATION_ID_ENV, format!(" {} ", minted.as_str()));
400 assert_eq!(FormationId::from_env().unwrap(), Some(minted));
401
402 std::env::set_var(FORMATION_ID_ENV, "frm_ABC");
403 let result = FormationId::from_env();
404 std::env::remove_var(FORMATION_ID_ENV);
405 assert!(matches!(
406 result,
407 Err(RuntimeError::FormationIdUnreadable { .. })
408 ));
409 }
410
411 #[test]
412 fn a_formation_id_serializes_as_a_bare_string_and_round_trips() {
413 let id = FormationId::mint();
414 let json = serde_json::to_string(&id).unwrap();
415 assert_eq!(json, format!("\"{}\"", id.as_str()));
416 let back: FormationId = serde_json::from_str(&json).unwrap();
417 assert_eq!(back, id);
418 }
419
420 #[test]
421 fn a_malformed_formation_id_does_not_deserialize_and_is_not_echoed() {
422 let minted = FormationId::mint();
423 let digits = &minted.as_str()[FORMATION_ID_PREFIX.len()..];
424 for value in [
425 "frm_ABC".to_string(),
426 format!("frm_{}", digits.to_uppercase()),
427 format!("ses_{digits}"),
428 ] {
429 let json = serde_json::to_string(&value).unwrap();
430 let error = serde_json::from_str::<FormationId>(&json)
431 .expect_err("a malformed id deserializes")
432 .to_string();
433 assert!(
434 !error.contains(&value),
435 "the error echoes {value:?}: {error}"
436 );
437 assert!(!error.contains(&digits[..16]), "{error}");
438 assert!(!error.contains(&digits[..16].to_uppercase()), "{error}");
439 assert!(error.contains("not a formation id"), "{error}");
440 }
441 assert!(serde_json::from_str::<FormationId>("42").is_err());
442 }
443
444 #[test]
445 fn a_formation_id_was_minted_when_its_uuid_says() {
446 let id = FormationId::mint();
447 let tail = &id.as_str()[FORMATION_ID_PREFIX.len()..];
448 assert_eq!(
449 Some(id.minted_at_ms()),
450 crate::retention::session_id_timestamp_ms(&format!("ses_{tail}"))
451 );
452 let now_ms = std::time::SystemTime::now()
453 .duration_since(std::time::UNIX_EPOCH)
454 .unwrap()
455 .as_millis() as u64;
456 assert!(id.minted_at_ms() <= now_ms);
457 assert!(now_ms - id.minted_at_ms() < 60_000);
458 let fixed = FormationId::parse("frm_00000000000a0000000000000000000f").unwrap();
459 assert_eq!(fixed.minted_at_ms(), 10);
460 }
461
462 fn sources_of(crate_dir: &str) -> Vec<(String, String)> {
464 crate::source_scan::sources_under(&crate::source_scan::sibling_crate_src(crate_dir))
465 .into_iter()
466 .map(|(path, text)| (format!("{crate_dir}/src/{path}"), text))
467 .collect()
468 }
469
470 const FORMATION_TOKENS: [&str; 4] = [
471 "FormationId",
472 "formation_id",
473 "FORMATION_ID_ENV",
474 "MURMUR_FORMATION_ID",
475 ];
476
477 #[test]
478 fn no_authorization_path_reads_a_formation_id() {
479 let own = sources_of("capsule-runtime");
480 let mut guarded = sources_of("mur-roost");
481 for file in [
482 "door_auth.rs",
483 "origin.rs",
484 "peer_handoff.rs",
485 "spawn_credential.rs",
486 "spawn_envelope.rs",
487 ] {
488 let path = format!("capsule-runtime/src/{file}");
489 let source = own
490 .iter()
491 .find(|(name, _)| *name == path)
492 .unwrap_or_else(|| panic!("{path} is gone"));
493 guarded.push(source.clone());
494 }
495 for (path, text) in guarded {
496 for token in FORMATION_TOKENS {
497 assert!(
498 !text.contains(token),
499 "{path} names {token}; a formation id confers no reachability"
500 );
501 }
502 }
503 }
504
505 fn env_reads(text: &str) -> Vec<&str> {
507 let mut calls = Vec::new();
508 for opener in ["env::var(", "env::var_os("] {
509 let mut rest = text;
510 while let Some(at) = rest.find(opener) {
511 let args = &rest[at + opener.len()..];
512 let mut depth = 1usize;
513 let end = args
514 .char_indices()
515 .find_map(|(index, ch)| {
516 match ch {
517 '(' => depth += 1,
518 ')' => depth -= 1,
519 _ => {}
520 }
521 (depth == 0).then_some(index)
522 })
523 .unwrap_or(args.len());
524 calls.push(&args[..end]);
525 rest = &args[end..];
526 }
527 }
528 calls
529 }
530
531 #[test]
532 fn the_formation_id_is_read_from_the_process_environment_in_one_place() {
533 let mut sources = sources_of("capsule-runtime");
534 sources.extend(sources_of("murmur-cli"));
535 let names_the_variable =
536 |args: &&str| args.contains("MURMUR_FORMATION_ID") || args.contains("FORMATION_ID_ENV");
537 let mut reads_here = 0;
538 for (path, text) in sources {
539 let reads = env_reads(crate::source_scan::production_part(&text))
540 .into_iter()
541 .filter(names_the_variable)
542 .count();
543 if path == "capsule-runtime/src/formation.rs" {
544 reads_here = reads;
545 } else {
546 assert_eq!(
547 reads, 0,
548 "{path} reads the formation id from the process environment; only \
549 FormationId::from_env does"
550 );
551 }
552 }
553 assert_eq!(reads_here, 1, "FormationId::from_env is the one reader");
554 }
555
556 #[test]
557 fn formation_peers_render_in_order() {
558 let peers = FormationPeers::new(vec![
559 FormationPeer {
560 name: "coder".to_string(),
561 url: "http://localhost:41873".to_string(),
562 },
563 FormationPeer {
564 name: "reviewer".to_string(),
565 url: "http://reviewer.formation.invalid".to_string(),
566 },
567 ])
568 .unwrap();
569 let value = "coder=http://localhost:41873 reviewer=http://reviewer.formation.invalid";
570 assert_eq!(peers.render(), value);
571 assert_eq!(peers.env_pair(), (FORMATION_PEERS_ENV, value.to_string()));
572 }
573
574 #[test]
575 fn formation_peers_refuse_every_malformed_peer() {
576 let refusal = |peers: &[(&str, &str)]| {
577 let peers = peers
578 .iter()
579 .map(|(name, url)| FormationPeer {
580 name: name.to_string(),
581 url: url.to_string(),
582 })
583 .collect();
584 match FormationPeers::new(peers) {
585 Err(error @ RuntimeError::FormationPeersUnreadable { .. }) => error.to_string(),
586 other => panic!("expected FormationPeersUnreadable, got {other:?}"),
587 }
588 };
589 for (peers, wording) in [
590 (
591 &[("Coder", "http://localhost:1")][..],
592 "must start with a lowercase letter",
593 ),
594 (
595 &[("co_der", "http://localhost:1")],
596 "pair 1 names a member whose name",
597 ),
598 (
599 &[
600 ("coder", "http://localhost:1"),
601 ("coder", "http://localhost:2"),
602 ],
603 "pair 2 names 'coder' a second time",
604 ),
605 (
606 &[("coder", "https://localhost:1")],
607 "does not start with 'http://'",
608 ),
609 (&[("coder", "localhost:1")], "does not start with 'http://'"),
610 (&[("coder", "http://localhost:0")], "port is not a number"),
611 (
612 &[("coder", "http://localhost:99999")],
613 "port is not a number",
614 ),
615 (&[("coder", "http://localhost:1/x")], "port is not a number"),
616 (&[("coder", "http://:1")], "host is not"),
617 ] {
618 let message = refusal(peers);
619 assert!(message.contains(wording), "{peers:?}: {message}");
620 assert!(message.contains(FORMATION_PEERS_ENV), "{message}");
621 }
622 assert!(FormationPeers::new(Vec::new()).is_err());
623 assert_eq!(
625 peer_url_format_error("http://localhost").as_deref(),
626 Some("has a URL with no port")
627 );
628 assert_eq!(peer_url_format_error("http://localhost:1"), None);
629 assert_eq!(peer_url_format_error("http://[::1]:9"), None);
630 assert!(peer_url_format_error("http://[::1]").is_some());
631 }
632
633 #[test]
634 fn formation_peers_in_the_process_environment_are_refused_and_never_read() {
635 let _guard = env_guard();
636 std::env::remove_var(FORMATION_PEERS_ENV);
637 assert!(refuse_formation_peers_in_process_env().is_ok());
638
639 for value in ["coder=http://localhost:7", ""] {
640 std::env::set_var(FORMATION_PEERS_ENV, value);
641 let refused = refuse_formation_peers_in_process_env();
642 std::env::remove_var(FORMATION_PEERS_ENV);
643 let message = refused.unwrap_err().to_string();
644 assert!(
645 message.contains("set in this process's environment"),
646 "{message}"
647 );
648 assert!(!message.contains("localhost"), "{message}");
649 }
650 }
651
652 #[test]
653 fn the_formation_peers_are_read_from_the_process_environment_in_one_place() {
654 let mut sources = sources_of("capsule-runtime");
655 sources.extend(sources_of("murmur-cli"));
656 let names_the_variable = |args: &&str| {
657 args.contains("MURMUR_FORMATION_PEERS") || args.contains("FORMATION_PEERS_ENV")
658 };
659 let mut reads_here = 0;
660 for (path, text) in sources {
661 let reads = env_reads(crate::source_scan::production_part(&text))
662 .into_iter()
663 .filter(names_the_variable)
664 .count();
665 if path == "capsule-runtime/src/formation.rs" {
666 reads_here = reads;
667 } else {
668 assert_eq!(
669 reads, 0,
670 "{path} reads the formation's peers from the process environment; nothing \
671 does but the refusal of a value found there"
672 );
673 }
674 }
675 assert_eq!(
676 reads_here, 1,
677 "refuse_formation_peers_in_process_env is the one place that looks"
678 );
679 }
680
681 #[test]
682 fn the_formation_lifeline_is_read_from_the_process_environment_in_one_place() {
683 let mut sources = sources_of("capsule-runtime");
684 sources.extend(sources_of("murmur-cli"));
685 let names_the_variable = |args: &&str| {
686 args.contains("MURMUR_FORMATION_LIFELINE") || args.contains("FORMATION_LIFELINE_ENV")
687 };
688 let mut reads_here = 0;
689 for (path, text) in sources {
690 let reads = env_reads(crate::source_scan::production_part(&text))
691 .into_iter()
692 .filter(names_the_variable)
693 .count();
694 if path == "capsule-runtime/src/lifeline.rs" {
695 reads_here = reads;
696 } else {
697 assert_eq!(
698 reads, 0,
699 "{path} reads the formation lifeline from the process environment; only \
700 FormationLifeline::from_env does"
701 );
702 }
703 }
704 assert_eq!(
705 reads_here, 1,
706 "FormationLifeline::from_env is the one reader"
707 );
708 }
709
710 #[test]
711 fn the_spawner_lifeline_is_read_from_the_process_environment_in_one_place() {
712 let mut sources = sources_of("capsule-runtime");
713 sources.extend(sources_of("murmur-cli"));
714 let names_the_variable = |args: &&str| {
715 args.contains("MURMUR_SPAWNER_LIFELINE") || args.contains("SPAWNER_LIFELINE_ENV")
716 };
717 let mut reads_here = 0;
718 for (path, text) in sources {
719 let reads = env_reads(crate::source_scan::production_part(&text))
720 .into_iter()
721 .filter(names_the_variable)
722 .count();
723 if path == "capsule-runtime/src/lifeline.rs" {
724 reads_here = reads;
725 } else {
726 assert_eq!(
727 reads, 0,
728 "{path} reads the spawner lifeline from the process environment; only \
729 SpawnerLifeline::from_env does"
730 );
731 }
732 }
733 assert_eq!(reads_here, 1, "SpawnerLifeline::from_env is the one reader");
734 }
735}