iso17442_types/
lib.rs

1//! ISO 17442 Types
2
3#![doc = include_str!("../README.md")]
4#![no_std]
5
6#[cfg(feature = "alloc")]
7extern crate alloc;
8
9#[cfg(feature = "alloc")]
10mod alloc_;
11#[cfg(feature = "serde")]
12mod serde_;
13
14#[cfg(feature = "serde")]
15use ::serde::{Deserialize, Serialize};
16use core::{
17    borrow::Borrow,
18    fmt::{Display, Formatter, Result as FmtResult},
19    num::ParseIntError,
20    ops::Deref,
21    str::FromStr,
22};
23use ref_cast::{RefCastCustom, ref_cast_custom};
24use thiserror::Error as ThisError;
25
26/// The size of a Legal Entity ID
27const LEI_SIZE: usize = 20;
28
29/// The size of an LOU
30const ISSUER_SIZE: usize = 4;
31
32const LOU_START: usize = 0;
33const LOU_END: usize = LOU_START + ISSUER_SIZE;
34
35/// The size of an entry
36const ID_SIZE: usize = 14;
37
38const ID_START: usize = LOU_END;
39const ID_END: usize = ID_START + ID_SIZE;
40
41/// The size of the checked portion of an LEI
42const CHECKED_SIZE: usize = ISSUER_SIZE + ID_SIZE;
43
44/// The position of the tens digit of the checksum
45const CHECK_TENS_POS: usize = 18;
46
47/// The position of the ones didit of the checksum
48const CHECK_ONES_POS: usize = 19;
49
50const fn validate(bytes: &[u8]) -> Result<(), Error> {
51    if bytes.len() != LEI_SIZE {
52        return Err(Error::InvalidLength(bytes.len(), LEI_SIZE));
53    }
54
55    let mut check_str_bytes = [0u8; LEI_SIZE * 2];
56
57    let mut i = 0;
58    let mut check_pos = 0;
59    while i < CHECKED_SIZE {
60        if bytes[i].is_ascii_uppercase() {
61            let checkval = bytes[i] - 55;
62            let tens = checkval / 10;
63            let ones = checkval % 10;
64            check_str_bytes[check_pos] = tens + 48;
65            check_pos += 1;
66            check_str_bytes[check_pos] = ones + 48;
67            check_pos += 1;
68        } else if bytes[i].is_ascii_digit() {
69            check_str_bytes[check_pos] = bytes[i];
70            check_pos += 1;
71        } else {
72            return Err(Error::InvalidCharacter(i));
73        }
74
75        i += 1;
76    }
77
78    check_str_bytes[check_pos] = b'0';
79    check_pos += 1;
80    check_str_bytes[check_pos] = b'0';
81    check_pos += 1;
82
83    let (check_bytes, _trailer) = check_str_bytes.as_slice().split_at(check_pos);
84
85    // SAFETY: We are building these bytes ourselves from ascii characters
86    #[allow(unsafe_code)]
87    let src = unsafe { str::from_utf8_unchecked(check_bytes) };
88
89    let result = u128::from_str_radix(src, 10);
90    if let Ok(check_sum) = result {
91        let check_digits = 98 - (check_sum % 97);
92        if check_digits < 1 || check_digits > 98 {
93            return Err(Error::CheckDigitFail);
94        }
95
96        #[allow(clippy::cast_possible_truncation)]
97        let tens = check_digits as u8 / 10;
98        #[allow(clippy::cast_possible_truncation)]
99        let ones = check_digits as u8 % 10;
100
101        if bytes[CHECK_TENS_POS] != tens + 48 || bytes[CHECK_ONES_POS] != ones + 48 {
102            Err(Error::CheckDigitFail)
103        } else {
104            Ok(())
105        }
106    } else {
107        Err(Error::CheckDigitParse)
108    }
109}
110
111/// An enumeration of errors
112#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, ThisError)]
113#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
114pub enum Error {
115    /// The string has the wrong length for an LEI.
116    #[error("The string has the wrong length for an LEI.")]
117    InvalidLength(usize, usize),
118
119    /// The string contains invalid characters for an LEI.
120    #[error("The string contains an invalid character at {0} for an LEI.")]
121    InvalidCharacter(usize),
122
123    /// The check digits string could not be parsed.
124    #[error("The check digits string could not be parsed.")]
125    CheckDigitParse,
126
127    /// The check digits did not validate.
128    #[error("The check digits did not validate.")]
129    CheckDigitFail,
130}
131
132impl From<ParseIntError> for Error {
133    fn from(_value: ParseIntError) -> Self {
134        Self::CheckDigitParse
135    }
136}
137
138/// A Legal Entity ID
139#[derive(Debug, Eq, Hash, Ord, PartialEq, PartialOrd, RefCastCustom)]
140#[repr(transparent)]
141#[allow(non_camel_case_types)]
142pub struct lei([u8]);
143
144impl lei {
145    #[ref_cast_custom]
146    pub(crate) const fn ref_cast(bytes: &[u8]) -> &Self;
147
148    /// Create a new LEI reference from a byte slice.
149    ///
150    /// # Errors
151    ///
152    /// - [`Error::InvalidLength`] when the given string is of the wrong length.
153    /// - [`Error::InvalidCharacter`] when the given string contains an invalid character.
154    /// - [`Error::CheckDigitParse`] when the check digits contain invalid characters.
155    /// - [`Error::CheckDigitFail`] when the check digit does not match the ID.
156    pub const fn from_bytes(bytes: &[u8]) -> Result<&Self, Error> {
157        if let Err(e) = validate(bytes) {
158            Err(e)
159        } else {
160            Ok(Self::ref_cast(bytes))
161        }
162    }
163
164    /// Create a new LEI reference from a string slice.
165    ///
166    /// # Errors
167    ///
168    /// - [`Error::InvalidLength`] when the given string is of the wrong length.
169    /// - [`Error::InvalidCharacter`] when the given string contains an invalid character.
170    /// - [`Error::CheckDigitParse`] when the check digits contain invalid characters.
171    /// - [`Error::CheckDigitFail`] when the check digit does not match the ID.
172    pub const fn from_str_slice(s: &str) -> Result<&Self, Error> {
173        lei::from_bytes(s.as_bytes())
174    }
175
176    /// Get a reference to the byte slice backing this string.
177    #[must_use]
178    pub const fn as_bytes(&self) -> &[u8] {
179        &self.0
180    }
181
182    /// Get a reference to the validated LEI reference as a string slice.
183    #[allow(unsafe_code)]
184    #[must_use]
185    pub const fn as_str(&self) -> &str {
186        // SAFETY: The validate function ensures that only ascii uppercase and digit characters are
187        // contined in this slice
188        unsafe { str::from_utf8_unchecked(&self.0) }
189    }
190
191    /// Split this LEI into three parts: issuer, ID, and check digit.
192    #[must_use]
193    #[expect(
194        clippy::missing_panics_doc,
195        reason = "Invariants failure in check digit validation"
196    )]
197    pub const fn split(&self) -> (&str, &str, u8) {
198        let whole = self.as_str();
199
200        let (issuer, remainder) = whole.split_at(LOU_END);
201        let (id, check_digits) = remainder.split_at(ID_END);
202
203        if let Ok(val) = u8::from_str_radix(check_digits, 10) {
204            (issuer, id, val)
205        } else {
206            panic!("Unparseable check digits somehow passed validation.");
207        }
208    }
209
210    /// The issuer of this LEI as a string slice.
211    #[must_use]
212    pub const fn lou(&self) -> &str {
213        let (issuer, _remainder) = self.as_str().split_at(LOU_END);
214        issuer
215    }
216
217    /// The ID part of this LEI as a string slice.
218    #[must_use]
219    pub const fn id(&self) -> &str {
220        let (_issuer, remainder) = self.as_str().split_at(LOU_END);
221        let (id, _remainder) = remainder.split_at(ID_END);
222        id
223    }
224
225    /// The check digit of this LEI, as an unsigned integer between 2 and 97.
226    #[must_use]
227    pub const fn check_digits(&self) -> u8 {
228        self.split().2
229    }
230}
231
232impl AsRef<[u8]> for lei {
233    fn as_ref(&self) -> &[u8] {
234        self.as_bytes()
235    }
236}
237
238impl AsRef<str> for lei {
239    fn as_ref(&self) -> &str {
240        self.as_str()
241    }
242}
243
244impl Display for lei {
245    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
246        write!(f, "{}", self.as_str())
247    }
248}
249
250/// An owned Legal Entity ID
251#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
252#[repr(transparent)]
253pub struct Lei([u8; LEI_SIZE]);
254
255impl Lei {
256    /// Create a new owned LEI from the given LEI borrow.
257    #[must_use]
258    pub const fn from_lei(src: &lei) -> Self {
259        Self::from_bytes_unchecked(src.as_bytes())
260    }
261
262    /// Create a new owned Legal Entity ID from the give byte slice.
263    ///
264    /// This will copy the bytes into a new owned LEI structure.
265    ///
266    /// # Examples
267    /// ```
268    /// use iso17442_types::Lei;
269    ///
270    /// const LEI_BYTES: &[u8] = b"YZ83GD8L7GG84979J516";
271    ///
272    /// let l = Lei::from_bytes(LEI_BYTES).expect("Could not parse LEI bytes");
273    ///
274    /// assert_eq!(LEI_BYTES, l.as_bytes());
275    /// ```
276    ///
277    /// # Errors
278    ///
279    /// - [`Error::InvalidLength`] when the given string is of the wrong length.
280    /// - [`Error::InvalidCharacter`] when the given string contains an invalid character.
281    /// - [`Error::CheckDigitParse`] when the check digits contain invalid characters.
282    /// - [`Error::CheckDigitFail`] when the check digit does not match the ID
283    pub const fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
284        if let Err(e) = validate(bytes) {
285            Err(e)
286        } else {
287            Ok(Self::from_bytes_unchecked(bytes))
288        }
289    }
290
291    /// Create a new owned LEI from the given byte array.
292    ///
293    /// # Examples
294    /// ```
295    /// use iso17442_types::Lei;
296    ///
297    /// const LEI_BYTES: [u8; 20] = *b"YZ83GD8L7GG84979J516";
298    ///
299    /// let l = Lei::from_byte_array(LEI_BYTES.clone()).expect("Could not parse LEI bytes");
300    ///
301    /// assert_eq!(&LEI_BYTES, l.as_bytes());
302    /// ```
303    ///
304    /// # Errors
305    ///
306    /// - [`Error::InvalidLength`] when the given string is of the wrong length.
307    /// - [`Error::InvalidCharacter`] when the given string contains an invalid character.
308    /// - [`Error::CheckDigitParse`] when the check digits contain invalid characters.
309    /// - [`Error::CheckDigitFail`] when the check digit does not match the ID.
310    pub const fn from_byte_array(bytes: [u8; LEI_SIZE]) -> Result<Self, Error> {
311        if let Err(e) = validate(&bytes) {
312            Err(e)
313        } else {
314            Ok(Self(bytes))
315        }
316    }
317
318    /// Create a new owned LEI from the given string slice.
319    ///
320    /// # Examples
321    /// ```
322    /// use iso17442_types::Lei;
323    ///
324    /// const LEI_STR: &str = "YZ83GD8L7GG84979J516";
325    ///
326    /// let l = Lei::from_str_slice(LEI_STR).expect("Could not parse LEI bytes");
327    ///
328    /// assert_eq!(LEI_STR, l.as_str());
329    /// ```
330    ///
331    /// # Errors
332    ///
333    /// - [`Error::InvalidLength`] when the given string is of the wrong length.
334    /// - [`Error::InvalidCharacter`] when the given string contains an invalid character.
335    /// - [`Error::CheckDigitParse`] when the check digits contain invalid characters.
336    /// - [`Error::CheckDigitFail`] when the check digit does not match the ID.
337    pub const fn from_str_slice(src: &str) -> Result<Self, Error> {
338        Self::from_bytes(src.as_bytes())
339    }
340
341    /// Copy the given slice into bytes
342    pub(crate) const fn from_bytes_unchecked(slice: &[u8]) -> Self {
343        let mut bytes = [0u8; LEI_SIZE];
344        bytes.copy_from_slice(slice);
345
346        Self(bytes)
347    }
348}
349
350impl Borrow<lei> for Lei {
351    fn borrow(&self) -> &lei {
352        self
353    }
354}
355
356impl Deref for Lei {
357    type Target = lei;
358
359    fn deref(&self) -> &Self::Target {
360        lei::ref_cast(&self.0)
361    }
362}
363
364impl Display for Lei {
365    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
366        write!(f, "{}", self.as_str())
367    }
368}
369
370impl From<&lei> for Lei {
371    fn from(value: &lei) -> Self {
372        Lei::from_lei(value)
373    }
374}
375
376impl TryFrom<[u8; LEI_SIZE]> for Lei {
377    type Error = Error;
378
379    fn try_from(bytes: [u8; LEI_SIZE]) -> Result<Self, Self::Error> {
380        Self::from_byte_array(bytes)
381    }
382}
383
384impl TryFrom<&[u8]> for Lei {
385    type Error = Error;
386
387    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
388        Self::from_bytes(bytes)
389    }
390}
391
392impl FromStr for Lei {
393    type Err = Error;
394
395    fn from_str(s: &str) -> Result<Self, Self::Err> {
396        Self::from_str_slice(s)
397    }
398}
399
400#[cfg(test)]
401mod test {
402    use super::*;
403    use alloc::borrow::ToOwned;
404    use core::{borrow::Borrow, str::FromStr};
405
406    #[yare::parameterized(
407        ok_1 = { "YZ83GD8L7GG84979J516", None },
408        poo = { "YZ83GD8L7GG849💩16", Some(Error::InvalidCharacter(14)) },
409        bad_check_1 = { "YZ83GD8L7GG84979J563", Some(Error::CheckDigitFail) },
410        bad_check_2 = { "315700K7NYVSQJNTN401", Some(Error::CheckDigitFail) },
411        missing_check = { "315700K7NYVSQJNTN4", Some(Error::InvalidLength(18, LEI_SIZE)) },
412        blank = { "", Some(Error::InvalidLength(0, LEI_SIZE)) },
413    )]
414    fn check(s: &str, err: Option<Error>) {
415        let result = lei::from_str_slice(s);
416        assert_eq!(err, result.err());
417
418        if let Ok(l) = result {
419            let owned = Lei::from_str(s).expect("Could not parse as owned?");
420            assert_eq!(l.to_owned(), owned);
421            assert_eq!(<Lei as Borrow<lei>>::borrow(&owned), l);
422        }
423    }
424}