whiskers.git / backend / worker / src / household.rs

Which household a request is for, as a type that cannot name anything else.

One Durable Object serves one household, and the object is addressed by this name. The name is therefore the isolation boundary between households, so it is checked once, here, and carried as a [HouseholdId] from then on.

7use std::fmt;

The header a test harness names a household in. Only read when the deployment trusts it (see [Households]).

11pub const HOUSEHOLD_HEADER: &str = "x-whiskers-household";

The longest id served. A Durable Object name may be 1024 bytes; a household's is far shorter.

14pub const MAX_LEN: usize = 63;

A household's name: 1 to 63 lowercase ASCII letters, digits or hyphens, not starting or ending with 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);
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum NotAnId {
23    Empty,
24    TooLong,

A character outside a-z 0-9 -, or a hyphen at either end.

26    BadShape,
27}
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}

How this deployment decides which household a request belongs to.

Whiskers' wire carries no credential today (the private network is the access control), so there is nothing to derive a household from: a deployment serves one household, named in its configuration. A harness that must start from an empty household for each test can instead be trusted to name one in a header; that is a testing mode, set only in .dev.vars, which neither wrangler deploy nor celld deploy reads. A header from a client is not authentication and must never be trusted in a deployment.

67#[derive(Clone, Debug, PartialEq, Eq)]
68pub enum Households {
69    Only(HouseholdId),
70    NamedByHeader { otherwise: HouseholdId },
71}

Why a request names no household we can serve.

74#[derive(Clone, Debug, PartialEq, Eq)]
75pub enum Unnamed {

The header was present and not an id. The client is wrong: a 400.

77    BadHeader(NotAnId),
78}
80impl Households {

From the two settings: HOUSEHOLD (required) and TRUST_HOUSEHOLD_HEADER ("1" to trust). 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    }

Whether this deployment is in the harness's test mode, which is exactly when it trusts a household 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    }

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}
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}