Skip to main content

mako_emob/
ids.rs

1//! The identifiers Modell 2 needs that the market does not issue.
2//!
3//! Everything the BDEW *does* issue is reused rather than redefined:
4//! [`rubo4e::identifiers::MaloId`] for the physical Marktlokation,
5//! [`mako_mabis::BilanzierungsgebietId`] and [`mako_mabis::BilanzkreisId`] for
6//! the virtual BG and the Bilanzkreise it books into,
7//! [`mako_mabis::MabisZaehlpunktId`] for the MaBiS-Zählpunkt.
8//!
9//! Three things have no issuing authority, and this module is careful about
10//! why.
11
12use std::fmt;
13
14use serde::{Deserialize, Serialize};
15
16/// A **virtual Marktlokation** inside the LPB's Bilanzierungsgebiet.
17///
18/// # Why this is not a `MaloId`
19///
20/// The BDEW issues MaLo-IDs as eleven digits with a check digit, from ranges
21/// delegated to Netzbetreiber. AWH Kap. 1.6.1 permits the LPB to use its
22/// Stromnetzbetreibernummer for **Zählpunktbildung und die BG-Beantragung** —
23/// and for nothing else. Nothing in Anlage 6 or the AWH grants an LPB a MaLo-ID
24/// range for the per-vehicle, per-token objects it needs internally.
25///
26/// So these IDs are deliberately **not** in the 11-digit space: minting a
27/// plausible-looking MaLo-ID would collide with a real one the moment a
28/// Netzbetreiber issued it, and the collision would surface as energy booked
29/// to a stranger's Bilanzkreis. A `VirtualMaloId` is opaque, namespaced by the
30/// operator, and [`VirtualMaloId::new`] refuses anything that could be mistaken
31/// for a MaLo-ID.
32///
33/// If the BDEW later opens a range, this type gains a variant — it does not
34/// become `MaloId`, because the two remain different objects.
35#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36pub struct VirtualMaloId(String);
37
38/// Why a [`VirtualMaloId`] was refused.
39#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
40pub enum InvalidVirtualMaloId {
41    /// Empty, or longer than [`VirtualMaloId::MAX_LEN`].
42    #[error("a virtual MaLo id must be 1..={max} characters, got {len}")]
43    Length {
44        /// The length supplied.
45        len: usize,
46        /// The maximum allowed.
47        max: usize,
48    },
49    /// Eleven digits — indistinguishable from a BDEW MaLo-ID.
50    #[error(
51        "'{0}' is eleven digits and would collide with the BDEW MaLo-ID space; \
52         namespace virtual Marktlokationen instead"
53    )]
54    LooksLikeMaloId(String),
55    /// A character outside `[A-Za-z0-9._:-]`.
56    #[error("'{0}' contains a character outside [A-Za-z0-9._:-]")]
57    Character(String),
58}
59
60impl VirtualMaloId {
61    /// The longest a virtual MaLo id may be.
62    pub const MAX_LEN: usize = 64;
63
64    /// Validate and wrap.
65    ///
66    /// # Errors
67    ///
68    /// [`InvalidVirtualMaloId::LooksLikeMaloId`] when `s` is exactly eleven
69    /// ASCII digits — see the type docs for why that is refused rather than
70    /// accepted.
71    pub fn new(s: impl Into<String>) -> Result<Self, InvalidVirtualMaloId> {
72        let s = s.into();
73        if s.is_empty() || s.len() > Self::MAX_LEN {
74            return Err(InvalidVirtualMaloId::Length {
75                len: s.len(),
76                max: Self::MAX_LEN,
77            });
78        }
79        if s.len() == 11 && s.bytes().all(|b| b.is_ascii_digit()) {
80            return Err(InvalidVirtualMaloId::LooksLikeMaloId(s));
81        }
82        if !s
83            .bytes()
84            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
85        {
86            return Err(InvalidVirtualMaloId::Character(s));
87        }
88        Ok(Self(s))
89    }
90
91    /// The wrapped value.
92    #[must_use]
93    pub fn as_str(&self) -> &str {
94        &self.0
95    }
96}
97
98impl fmt::Display for VirtualMaloId {
99    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100        f.write_str(&self.0)
101    }
102}
103
104/// The identifier of one Ladevorgang, as the CPO backend knows it.
105///
106/// Free-form on purpose: OCPI calls it a `CDR.id`, OCPP a `transactionId`, and
107/// a device log may have neither. What matters here is only that it is stable
108/// enough to deduplicate a late-arriving CDR against a value already allocated.
109#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
110pub struct SessionId(String);
111
112impl SessionId {
113    /// Wrap a session identifier.
114    #[must_use]
115    pub fn new(s: impl Into<String>) -> Self {
116        Self(s.into())
117    }
118
119    /// The wrapped value.
120    #[must_use]
121    pub fn as_str(&self) -> &str {
122        &self.0
123    }
124}
125
126impl fmt::Display for SessionId {
127    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128        f.write_str(&self.0)
129    }
130}
131
132/// A reference to the contract token a session authenticated with — **never the
133/// token itself**.
134///
135/// An RFID UID or an eMAID identifies a natural person's charging contract
136/// across every operator they visit. It is personal data under Art. 4 Nr. 1
137/// GDPR, and the allocation does not need it: the allocation needs to know
138/// *which virtual MaLo* a session belongs to, which is a lookup the token
139/// registry performs once, upstream.
140///
141/// So this type carries an opaque, keyed hash produced by that registry.
142/// Nothing here can reverse it, and a leaked allocation ledger discloses no
143/// contract identities. Unknown tokens are not an error — they route to the
144/// Residual-MaLo.
145#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
146pub struct TokenRef(String);
147
148impl TokenRef {
149    /// Wrap a keyed hash produced by the token registry.
150    ///
151    /// The caller is responsible for the keying; this type only guarantees
152    /// that a raw token never reaches the allocation ledger by *accident*.
153    #[must_use]
154    pub fn from_keyed_hash(hash: impl Into<String>) -> Self {
155        Self(hash.into())
156    }
157
158    /// The wrapped hash.
159    #[must_use]
160    pub fn as_str(&self) -> &str {
161        &self.0
162    }
163}
164
165impl fmt::Display for TokenRef {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.write_str(&self.0)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    /// A check-digit-valid MaLo-ID: the one that would actually collide.
176    #[test]
177    fn an_eleven_digit_id_is_refused() {
178        let err = VirtualMaloId::new("51238297068").unwrap_err();
179        assert!(matches!(err, InvalidVirtualMaloId::LooksLikeMaloId(_)));
180    }
181
182    /// Eleven digits are refused on their shape alone — the check digit is
183    /// never consulted, because a Netzbetreiber issuing the valid neighbour of
184    /// a minted id collides just as hard.
185    #[test]
186    fn eleven_digits_are_refused_even_with_a_wrong_check_digit() {
187        assert!(VirtualMaloId::new("51238297069").is_err());
188    }
189
190    /// Eleven *characters* are fine — it is the all-digit shape that collides.
191    #[test]
192    fn eleven_characters_that_are_not_all_digits_are_fine() {
193        assert!(VirtualMaloId::new("veh-1234567").is_ok());
194    }
195
196    #[test]
197    fn other_digit_lengths_are_fine() {
198        assert!(VirtualMaloId::new("512382970").is_ok());
199        assert!(VirtualMaloId::new("512382970699").is_ok());
200    }
201
202    #[test]
203    fn empty_and_overlong_are_refused() {
204        assert!(VirtualMaloId::new("").is_err());
205        assert!(VirtualMaloId::new("x".repeat(VirtualMaloId::MAX_LEN + 1)).is_err());
206    }
207
208    #[test]
209    fn separators_are_allowed_but_spaces_are_not() {
210        assert!(VirtualMaloId::new("cpo:fleet.42_a-b").is_ok());
211        assert!(VirtualMaloId::new("cpo fleet").is_err());
212    }
213}