1#![cfg_attr(docsrs, feature(doc_cfg))]
16
17pub mod registration_authority;
19
20use rand::Rng;
21
22#[derive(Debug, PartialEq, thiserror::Error)]
24pub enum Error {
25 #[error("invalid length: {0}, expected 20")]
27 InvalidLength(usize),
28 #[error("invalid checksum")]
30 InvalidChecksum,
31 #[error("invalid character at position {pos}: {char}")]
33 InvalidChar { pos: usize, char: char },
34 #[error("unknown registration authority: {0}")]
36 UnknownRegistrationAuthority(String),
37}
38
39type Result<T> = std::result::Result<T, Error>;
40
41#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
45#[cfg_attr(
46 feature = "diesel",
47 derive(diesel::deserialize::FromSqlRow, diesel::expression::AsExpression)
48)]
49#[cfg_attr(feature = "diesel", diesel(sql_type = diesel::sql_types::Text))]
50#[serde(transparent)]
51pub struct LEI {
52 lei: String,
53}
54
55impl<'de> serde::Deserialize<'de> for LEI {
56 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
57 let string: String = serde::Deserialize::deserialize(d)?;
58 string.as_str().try_into().map_err(serde::de::Error::custom)
59 }
60}
61
62#[cfg(feature = "async-graphql")]
63#[cfg_attr(docsrs, doc(cfg(feature = "async-graphql")))]
64async_graphql::scalar!(LEI);
65
66impl std::fmt::Display for LEI {
67 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
68 self.lei.fmt(f)
69 }
70}
71
72impl TryFrom<&str> for LEI {
73 type Error = Error;
74 fn try_from(from: &str) -> Result<Self> {
75 if from.len() != 20 {
76 return Err(Error::InvalidLength(from.len()));
77 }
78 if !validate_checksum(from) {
79 return Err(Error::InvalidChecksum);
80 }
81 Ok(Self { lei: from.into() })
82 }
83}
84
85#[cfg(feature = "diesel")]
86#[cfg_attr(docsrs, doc(cfg(feature = "diesel")))]
87impl<DB> diesel::deserialize::FromSql<diesel::sql_types::Text, DB> for LEI
88where
89 DB: diesel::backend::Backend,
90 String: diesel::deserialize::FromSql<diesel::sql_types::Text, DB>,
91{
92 fn from_sql(bytes: DB::RawValue<'_>) -> diesel::deserialize::Result<Self> {
93 Ok(std::convert::TryFrom::try_from(
94 String::from_sql(bytes)?.as_str(),
95 )?)
96 }
97}
98
99#[cfg(feature = "diesel")]
100#[cfg_attr(docsrs, doc(cfg(feature = "diesel")))]
101impl<DB> diesel::serialize::ToSql<diesel::sql_types::Text, DB> for LEI
102where
103 DB: diesel::backend::Backend,
104 str: diesel::serialize::ToSql<diesel::sql_types::Text, DB>,
105{
106 fn to_sql<'b>(
107 &'b self,
108 out: &mut diesel::serialize::Output<'b, '_, DB>,
109 ) -> diesel::serialize::Result {
110 self.lei.as_str().to_sql(out)
111 }
112}
113
114impl LEI {
115 pub fn random() -> Self {
118 let mut rng = rand::thread_rng();
119 let prefix: String = (0..4)
120 .map(|_| rng.sample(rand::distributions::Alphanumeric))
121 .map(char::from)
122 .collect::<String>()
123 .to_uppercase();
124 let infix: String = (0..12)
125 .map(|_| rng.sample(rand::distributions::Alphanumeric))
126 .map(char::from)
127 .collect::<String>()
128 .to_uppercase();
129 let checksum = 98 - mod_97(&format!("{prefix}00{infix}00")).unwrap();
131 Self::try_from(format!("{prefix}00{infix}{checksum:02}").as_str()).unwrap()
132 }
133}
134
135fn validate_checksum(lei: &str) -> bool {
136 mod_97(lei).map_or_else(|_| false, |m| m == 1)
137}
138
139fn mod_97(lei: &str) -> Result<u32> {
140 lei.as_bytes()
141 .iter()
142 .enumerate()
143 .try_fold(0, |acc, (i, c)| {
144 let digit = (*c as char).to_digit(36).ok_or(Error::InvalidChar {
146 pos: i,
147 char: *c as char,
148 })?;
149 let multiplier = if digit > 9 { 100 } else { 10 };
150 Ok((acc * multiplier + digit) % 97)
151 })
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn test_mod_97() {
160 assert_eq!(mod_97("").unwrap(), 0);
161 assert_eq!(mod_97("1").unwrap(), 1);
162 assert_eq!(mod_97("02").unwrap(), 2);
163 assert_eq!(mod_97("96").unwrap(), 96);
164 assert_eq!(mod_97("97").unwrap(), 0);
165 assert_eq!(mod_97("98").unwrap(), 1);
166 assert_eq!(mod_97("9799").unwrap(), 2);
167 assert_eq!(
168 mod_97("-1").unwrap_err(),
169 Error::InvalidChar { pos: 0, char: '-' }
170 );
171 assert_eq!(
172 mod_97("123#").unwrap_err(),
173 Error::InvalidChar { pos: 3, char: '#' }
174 );
175 }
176
177 #[test]
178 fn test_happy_parse() {
179 LEI::try_from("2594007XIACKNMUAW223").unwrap();
181 LEI::try_from("54930084UKLVMY22DS16").unwrap();
183 LEI::try_from("213800WSGIIZCXF1P572").unwrap();
184 LEI::try_from("5493000IBP32UQZ0KL24").unwrap();
185 LEI::try_from("RILFO74KP1CM8P6PCT96").unwrap();
187 }
188
189 #[test]
190 fn test_malformed() {
191 for lei in ["", "2594007XIACKNUAW223", "2594007XIACKNUAW22334"] {
192 assert_eq!(
193 LEI::try_from(lei).unwrap_err(),
194 Error::InvalidLength(lei.len())
195 );
196 }
197 assert_eq!(
198 LEI::try_from("2594007XIACKNMUAW224").unwrap_err(),
199 Error::InvalidChecksum
200 );
201 }
202
203 #[test]
204 fn test_random() {
205 LEI::random();
206 }
207}