Skip to main content

tatara_process/
env.rs

1//! `EphemeralEnvId` — the typed, validate-by-construction identity of an
2//! ephemeral environment (the Dev-Loop "EnvId" keystone; Confluence "9 ·
3//! Ephemeral Environments").
4//!
5//! A facade over the existing stringly derivation
6//! ([`ephemeral_id_from_spec`](crate::hostname::ephemeral_id_from_spec)) — *same
7//! hash, now a newtype* so an invalid id is **unrepresentable**: the only ways to
8//! build one are [`from_spec`](EphemeralEnvId::from_spec) (derive — always valid,
9//! idempotent: same spec ⇒ same id ⇒ same DNS slot) and
10//! [`parse`](EphemeralEnvId::parse) (validate at an untrusted boundary —
11//! parse-don't-validate). Deserialization routes through `parse`, so a malformed
12//! id in a CRD/label/wire is **parse-time-rejected** (the eclusa §III.5 wire
13//! discipline), never an in-flight value. Existing call sites keep using the
14//! string fns unchanged; new typed code uses this newtype.
15
16use serde::{Deserialize, Serialize};
17
18use crate::hostname::{ephemeral_id_from_spec, HostnameError, EPHEMERAL_ID_HASH_LEN};
19
20/// Why a string is not a valid [`EphemeralEnvId`].
21#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
22pub enum EnvIdError {
23    #[error("ephemeral env id must be {EPHEMERAL_ID_HASH_LEN} chars, got {0}")]
24    WrongLength(usize),
25    #[error("ephemeral env id must be lowercase hex [0-9a-f], got {0:?}")]
26    NotLowercaseHex(String),
27}
28
29/// The deterministic identity of an ephemeral environment — exactly
30/// [`EPHEMERAL_ID_HASH_LEN`] lowercase-hex chars of BLAKE3 over a spec's
31/// canonical JSON. Validate-by-construction (see the module docs).
32#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
33#[serde(transparent)]
34pub struct EphemeralEnvId(String);
35
36impl EphemeralEnvId {
37    /// Derive from a spec — the idempotent content-hash form (same value as
38    /// [`ephemeral_id_from_spec`](crate::hostname::ephemeral_id_from_spec)). The
39    /// derivation guarantees the invariant, so this only fails if the spec itself
40    /// can't canonicalize.
41    pub fn from_spec<T: Serialize>(spec: &T) -> Result<Self, HostnameError> {
42        Ok(Self(ephemeral_id_from_spec(spec)?))
43    }
44
45    /// Parse + validate an id from an untrusted string (a CRD status, a label, an
46    /// operator message) — the boundary where a bad id is rejected.
47    pub fn parse(s: &str) -> Result<Self, EnvIdError> {
48        if s.len() != EPHEMERAL_ID_HASH_LEN {
49            return Err(EnvIdError::WrongLength(s.len()));
50        }
51        if !s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
52            return Err(EnvIdError::NotLowercaseHex(s.to_string()));
53        }
54        Ok(Self(s.to_string()))
55    }
56
57    #[must_use]
58    pub fn as_str(&self) -> &str {
59        &self.0
60    }
61}
62
63impl std::fmt::Display for EphemeralEnvId {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.write_str(&self.0)
66    }
67}
68
69impl<'de> Deserialize<'de> for EphemeralEnvId {
70    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
71        let s = String::deserialize(d)?;
72        Self::parse(&s).map_err(serde::de::Error::custom)
73    }
74}
75
76impl schemars::JsonSchema for EphemeralEnvId {
77    fn schema_name() -> String {
78        "EphemeralEnvId".into()
79    }
80    fn json_schema(g: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
81        let mut s = <String as schemars::JsonSchema>::json_schema(g);
82        if let schemars::schema::Schema::Object(ref mut o) = s {
83            o.string = Some(Box::new(schemars::schema::StringValidation {
84                pattern: Some(format!("^[0-9a-f]{{{EPHEMERAL_ID_HASH_LEN}}}$")),
85                min_length: Some(EPHEMERAL_ID_HASH_LEN as u32),
86                max_length: Some(EPHEMERAL_ID_HASH_LEN as u32),
87                ..Default::default()
88            }));
89        }
90        s
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::{EnvIdError, EphemeralEnvId};
97    use crate::hostname::{ephemeral_id_from_spec, EPHEMERAL_ID_HASH_LEN};
98    use serde::Serialize;
99
100    #[derive(Serialize)]
101    struct DummySpec {
102        name: String,
103        n: u32,
104    }
105
106    #[test]
107    fn from_spec_is_deterministic_and_matches_the_raw_fn() {
108        let spec = DummySpec { name: "gateway".into(), n: 7 };
109        let a = EphemeralEnvId::from_spec(&spec).unwrap();
110        let b = EphemeralEnvId::from_spec(&spec).unwrap();
111        assert_eq!(a, b, "same spec ⇒ same id (idempotent)");
112        // the newtype value IS the existing derivation
113        assert_eq!(a.as_str(), ephemeral_id_from_spec(&spec).unwrap());
114        assert_eq!(a.as_str().len(), EPHEMERAL_ID_HASH_LEN);
115    }
116
117    #[test]
118    fn different_specs_yield_different_ids() {
119        let a = EphemeralEnvId::from_spec(&DummySpec { name: "a".into(), n: 1 }).unwrap();
120        let b = EphemeralEnvId::from_spec(&DummySpec { name: "a".into(), n: 2 }).unwrap();
121        assert_ne!(a, b);
122    }
123
124    #[test]
125    fn parse_accepts_valid_and_rejects_invalid() {
126        let valid = "0a1b2c3d"; // 8 lowercase-hex
127        assert_eq!(EphemeralEnvId::parse(valid).unwrap().as_str(), valid);
128        assert!(matches!(EphemeralEnvId::parse("0a1b").unwrap_err(), EnvIdError::WrongLength(4)));
129        assert!(matches!(EphemeralEnvId::parse("0a1b2c3d4e").unwrap_err(), EnvIdError::WrongLength(10)));
130        // uppercase / non-hex rejected
131        assert!(matches!(EphemeralEnvId::parse("0A1B2C3D").unwrap_err(), EnvIdError::NotLowercaseHex(_)));
132        assert!(matches!(EphemeralEnvId::parse("0a1b2c3z").unwrap_err(), EnvIdError::NotLowercaseHex(_)));
133    }
134
135    #[test]
136    fn serde_round_trips_and_deserialize_rejects_malformed() {
137        let id = EphemeralEnvId::parse("deadbeef").unwrap();
138        let json = serde_json::to_string(&id).unwrap();
139        assert_eq!(json, "\"deadbeef\"");
140        let back: EphemeralEnvId = serde_json::from_str(&json).unwrap();
141        assert_eq!(back, id);
142        // a malformed id in the wire is parse-time-rejected, never an in-Rust value
143        assert!(serde_json::from_str::<EphemeralEnvId>("\"NOTHEX!!\"").is_err());
144        assert!(serde_json::from_str::<EphemeralEnvId>("\"short\"").is_err());
145    }
146}