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}