thruput_xdr/base58.rs
1//! Allocation-free Bitcoin/Solana base58 conversion for identifier bytes.
2//!
3//! These helpers do not select an encoding based on byte length. Public API
4//! adapters select identifier fields; opaque payloads continue to use hex.
5//! StrKey input policy belongs to higher-level clients.
6
7use core::fmt;
8
9/// A conversion failure. Neither formatting nor debug output includes input.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum Error {
13 /// The output buffer cannot hold the encoded text or decoded bytes.
14 ///
15 /// [`decode_fixed`] also reports input that decodes to more than its
16 /// `N` bytes this way, since it decodes into an `N`-byte buffer.
17 BufferTooSmall,
18 /// The input contains a character outside the case-sensitive alphabet.
19 InvalidEncoding,
20 /// Decoding succeeded but the identifier has the wrong byte length.
21 ///
22 /// [`decode_fixed`] reports only too-short input this way; too-long
23 /// input is [`Error::BufferTooSmall`].
24 InvalidLength {
25 /// Required byte length.
26 expected: usize,
27 /// Decoded byte length.
28 actual: usize,
29 },
30}
31
32impl fmt::Display for Error {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 match self {
35 Self::BufferTooSmall => {
36 f.write_str("base58 output buffer is too small")
37 }
38 Self::InvalidEncoding => f.write_str("invalid base58 encoding"),
39 Self::InvalidLength { expected, actual } => {
40 write!(f, "expected {expected} bytes, received {actual}")
41 }
42 }
43 }
44}
45
46impl core::error::Error for Error {}
47
48/// Conservative text bound for a decoded byte limit; saturates on overflow.
49///
50/// A value within this bound can still exceed the decoded limit. Empty bytes
51/// encode as empty text. The ratio 1.38 exceeds log(256) / log(58).
52pub const fn max_encoded_len(max_bytes: usize) -> usize {
53 if max_bytes == 0 {
54 return 0;
55 }
56 (max_bytes / 100)
57 .saturating_mul(138)
58 .saturating_add((max_bytes % 100) * 138 / 100)
59 .saturating_add(1)
60}
61
62/// Encode bytes into the start of `output`, returning the written length.
63///
64/// Preserves leading zeros and adds no prefix. A buffer of
65/// [`max_encoded_len(bytes.len())`](max_encoded_len) bytes is sufficient.
66/// On error, the output buffer may have been modified.
67pub fn encode_into(bytes: &[u8], output: &mut [u8]) -> Result<usize, Error> {
68 bs58::encode(bytes)
69 .onto(output)
70 .map_err(|_| Error::BufferTooSmall)
71}
72
73/// Decode into the start of `output`, returning the written byte length.
74///
75/// Rejects whitespace and invalid characters without echoing the input. The
76/// buffer length bounds decoded size and is used to reject oversized text
77/// before conversion. Conversion has quadratic cost; use a field-appropriate
78/// buffer size for untrusted input. On error, the buffer may be modified.
79pub fn decode_into(text: &str, output: &mut [u8]) -> Result<usize, Error> {
80 if text.len() > max_encoded_len(output.len()) {
81 return Err(Error::BufferTooSmall);
82 }
83 bs58::decode(text)
84 .onto(output)
85 .map_err(|error| match error {
86 bs58::decode::Error::BufferTooSmall => Error::BufferTooSmall,
87 _ => Error::InvalidEncoding,
88 })
89}
90
91/// Decode an identifier of exactly `N` bytes, with no heap allocation.
92///
93/// Input that decodes to fewer than `N` bytes is [`Error::InvalidLength`];
94/// input that decodes to more is [`Error::BufferTooSmall`].
95///
96/// ```
97/// use thruput_xdr::base58::decode_fixed;
98/// assert_eq!(decode_fixed::<4>("119p")?, [0, 0, 1, 255]);
99/// # Ok::<(), thruput_xdr::base58::Error>(())
100/// ```
101pub fn decode_fixed<const N: usize>(text: &str) -> Result<[u8; N], Error> {
102 let mut bytes = [0; N];
103 let actual = decode_into(text, &mut bytes)?;
104 if actual != N {
105 return Err(Error::InvalidLength {
106 expected: N,
107 actual,
108 });
109 }
110 Ok(bytes)
111}
112
113/// Borrowed fixed-size bytes formatted as unprefixed base58.
114///
115/// Construct with [`base58_encode`]. Formatting uses `2 * N` bytes of stack
116/// scratch space and no heap allocation; intended for small identifiers.
117pub struct Base58<'a, const N: usize>(&'a [u8; N]);
118
119impl<const N: usize> fmt::Display for Base58<'_, N> {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 // Two characters per byte is sufficient even for a single byte.
122 // Nested arrays keep the buffer size tied to N on stable Rust.
123 let mut buffer = [[0u8; 2]; N];
124 let buffer = buffer.as_flattened_mut();
125 let len = encode_into(self.0, buffer).map_err(|_| fmt::Error)?;
126 let text =
127 core::str::from_utf8(&buffer[..len]).map_err(|_| fmt::Error)?;
128 f.write_str(text)
129 }
130}
131
132impl<const N: usize> fmt::Debug for Base58<'_, N> {
133 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134 fmt::Display::fmt(self, f)
135 }
136}
137
138/// Wrap fixed-size identifier bytes for allocation-free base58 formatting.
139///
140/// ```
141/// use thruput_xdr::base58::base58_encode;
142/// assert_eq!(format!("{}", base58_encode(&[0, 0, 1, 255])), "119p");
143/// ```
144pub fn base58_encode<const N: usize>(bytes: &[u8; N]) -> Base58<'_, N> {
145 Base58(bytes)
146}
147
148#[cfg(test)]
149mod tests;