1//! Which household a request is for, as a type that cannot name anything else. 2//! 3//! One Durable Object serves one household, and the object is addressed by this name. The name is 4//! therefore the isolation boundary between households, so it is checked once, here, and carried as 5//! a [`HouseholdId`] from then on. 6 7use std::fmt; 8 9/// The header a test harness names a household in. Only read when the deployment trusts it 10/// (see [`Households`]). 11pub const HOUSEHOLD_HEADER: &str = "x-whiskers-household"; 12 13/// The longest id served. A Durable Object name may be 1024 bytes; a household's is far shorter. 14pub const MAX_LEN: usize = 63; 15 16/// A household's name: 1 to 63 lowercase ASCII letters, digits or hyphens, not starting or ending with 17/// a hyphen (the same shape celld requires of a Worker's name, so one rule serves both hosts). 18#[derive(Clone, Debug, PartialEq, Eq, Hash)] 19pub struct HouseholdId(String); 20 21#[derive(Clone, Copy, Debug, PartialEq, Eq)] 22pub enum NotAnId { 23 Empty, 24 TooLong, 25 /// A character outside `a-z 0-9 -`, or a hyphen at either end. 26 BadShape, 27} 28 29impl fmt::Display for NotAnId { 30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 31 match self { 32 NotAnId::Empty => write!(f, "a household id is empty"), 33 NotAnId::TooLong => write!(f, "a household id is longer than {MAX_LEN} characters"), 34 NotAnId::BadShape => write!(f, "a household id is lowercase letters, digits and inner hyphens only"), 35 } 36 } 37} 38 39impl HouseholdId { 40 pub fn parse(text: &str) -> Result<Self, NotAnId> { 41 if text.is_empty() { 42 return Err(NotAnId::Empty); 43 } 44 if text.len() > MAX_LEN { 45 return Err(NotAnId::TooLong); 46 } 47 let plain = text.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); 48 if !plain || text.starts_with('-') || text.ends_with('-') { 49 return Err(NotAnId::BadShape); 50 } 51 Ok(Self(text.to_owned())) 52 } 53 54 pub fn as_str(&self) -> &str { 55 &self.0 56 } 57} 58 59/// How this deployment decides which household a request belongs to. 60/// 61/// Whiskers' wire carries no credential today (the private network is the access control), so there is 62/// nothing to derive a household from: a deployment serves **one** household, named in its 63/// configuration. A harness that must start from an empty household for each test can instead be 64/// *trusted* to name one in a header; that is a testing mode, set only in `.dev.vars`, which neither 65/// `wrangler deploy` nor `celld deploy` reads. A header from a client is not authentication and must 66/// never be trusted in a deployment. 67#[derive(Clone, Debug, PartialEq, Eq)] 68pub enum Households { 69 Only(HouseholdId), 70 NamedByHeader { otherwise: HouseholdId }, 71} 72 73/// Why a request names no household we can serve. 74#[derive(Clone, Debug, PartialEq, Eq)] 75pub enum Unnamed { 76 /// The header was present and not an id. The client is wrong: a 400. 77 BadHeader(NotAnId), 78} 79 80impl Households { 81 /// From the two settings: `HOUSEHOLD` (required) and `TRUST_HOUSEHOLD_HEADER` (`"1"` to trust). 82 /// A configuration that does not name a valid household is an error for the operator. 83 pub fn from_settings(household: Option<&str>, trust_header: Option<&str>) -> Result<Self, NotAnId> { 84 let id = HouseholdId::parse(household.unwrap_or(""))?; 85 Ok(if trust_header == Some("1") { Households::NamedByHeader { otherwise: id } } else { Households::Only(id) }) 86 } 87 88 /// Whether this deployment is in the harness's test mode, which is exactly when it trusts a household 89 /// header: one switch, so the test-only routes cannot be on without it. 90 pub fn is_testing(&self) -> bool { 91 matches!(self, Households::NamedByHeader { .. }) 92 } 93 94 /// The household a request is for. `header` is the value of [`HOUSEHOLD_HEADER`], if the request had one. 95 pub fn of(&self, header: Option<&str>) -> Result<HouseholdId, Unnamed> { 96 match (self, header) { 97 (Households::NamedByHeader { .. }, Some(h)) => HouseholdId::parse(h).map_err(Unnamed::BadHeader), 98 (Households::NamedByHeader { otherwise: id } | Households::Only(id), _) => Ok(id.clone()), 99 } 100 } 101} 102 103#[cfg(test)] 104mod tests { 105 use super::*; 106 107 #[test] 108 fn an_id_is_a_plain_lowercase_name() { 109 assert!(HouseholdId::parse("home").is_ok()); 110 assert!(HouseholdId::parse("a-1-b").is_ok()); 111 assert_eq!(HouseholdId::parse(""), Err(NotAnId::Empty)); 112 assert_eq!(HouseholdId::parse(&"a".repeat(64)), Err(NotAnId::TooLong)); 113 assert!(HouseholdId::parse(&"a".repeat(63)).is_ok()); 114 for bad in ["Home", "-a", "a-", "a b", "a/b", "é", "a.b", "a_b"] { 115 assert_eq!(HouseholdId::parse(bad), Err(NotAnId::BadShape), "{bad:?}"); 116 } 117 } 118 119 #[test] 120 fn a_deployment_serves_its_one_household_whatever_a_client_sends() { 121 let h = Households::from_settings(Some("home"), None).unwrap(); 122 assert_eq!(h.of(None).unwrap().as_str(), "home"); 123 assert_eq!(h.of(Some("other")).unwrap().as_str(), "home", "an untrusted header is not read"); 124 assert_eq!(h.of(Some("NOT AN ID")).unwrap().as_str(), "home", "nor validated"); 125 let h = Households::from_settings(Some("home"), Some("0")).unwrap(); 126 assert_eq!(h.of(Some("other")).unwrap().as_str(), "home"); 127 } 128 129 #[test] 130 fn a_trusted_header_names_the_household_and_a_bad_one_is_refused() { 131 let h = Households::from_settings(Some("home"), Some("1")).unwrap(); 132 assert_eq!(h.of(Some("test-7")).unwrap().as_str(), "test-7"); 133 assert_eq!(h.of(None).unwrap().as_str(), "home"); 134 assert_eq!(h.of(Some("A")), Err(Unnamed::BadHeader(NotAnId::BadShape))); 135 } 136 137 #[test] 138 fn only_the_trusting_mode_is_a_test_mode() { 139 assert!(!Households::from_settings(Some("home"), None).unwrap().is_testing()); 140 assert!(!Households::from_settings(Some("home"), Some("true")).unwrap().is_testing(), "only \"1\" turns it on"); 141 assert!(Households::from_settings(Some("home"), Some("1")).unwrap().is_testing()); 142 } 143 144 #[test] 145 fn a_configuration_without_a_household_is_an_error() { 146 assert_eq!(Households::from_settings(None, None), Err(NotAnId::Empty)); 147 assert_eq!(Households::from_settings(Some("Bad"), Some("1")), Err(NotAnId::BadShape)); 148 } 149}