Skip to main content

leim/
lib.rs

1//! # Rust LEI Library
2//!
3//! This crate provides functionality to work with Legal Entity
4//! Identifiers (LEIs):
5//!
6//! ```
7//! use leim as lei;
8//! assert!(lei::LEI::try_from("2594007XIACKNMUAW223").is_ok());
9//! assert_eq!(
10//!     lei::LEI::try_from("2594007XIACKNMUAW222"),
11//!     Err(lei::Error::InvalidChecksum)
12//! );
13//! ```
14
15#![cfg_attr(docsrs, feature(doc_cfg))]
16
17/// Functionality related to registration authorities.
18pub mod registration_authority;
19
20use rand::Rng;
21
22/// The errors emitted when parsing a LEI.
23#[derive(Debug, PartialEq, thiserror::Error)]
24pub enum Error {
25    /// The LEI had an invalid length.
26    #[error("invalid length: {0}, expected 20")]
27    InvalidLength(usize),
28    /// The LEI had an invalid checksum.
29    #[error("invalid checksum")]
30    InvalidChecksum,
31    /// The LEI contained an invalid character.
32    #[error("invalid character at position {pos}: {char}")]
33    InvalidChar { pos: usize, char: char },
34    /// The registration authority was not known.
35    #[error("unknown registration authority: {0}")]
36    UnknownRegistrationAuthority(String),
37}
38
39type Result<T> = std::result::Result<T, Error>;
40
41/// A 20-character Legal Entity Identifier. The checksum validation
42/// happens according to ISO7064, similarly to  IBAN numbers.
43/// <https://www.gleif.org/en/about-lei/iso-17442-the-lei-code-structure>
44#[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    /// Constructs a random LEI with a valid checksum (only for
116    /// testing purposes).
117    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        // Use placeholder 0s to compute needed checksum
130        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            // Convert '0'-'Z' to 0-35
145            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        // from https://lei.info/portal/resources/lei-code/
180        LEI::try_from("2594007XIACKNMUAW223").unwrap();
181        // from https://en.wikipedia.org/wiki/Legal_Entity_Identifier
182        LEI::try_from("54930084UKLVMY22DS16").unwrap();
183        LEI::try_from("213800WSGIIZCXF1P572").unwrap();
184        LEI::try_from("5493000IBP32UQZ0KL24").unwrap();
185        // Standard Chartered Bank
186        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}