whiskers.git / backend / worker / src / household.rs
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}