Skip to main content

squishyid/
lib.rs

1//! Shortens and obfuscates your IDs at the same time.
2//!
3//! Useful for:
4//! - Hiding real database IDs in URLs or REST APIs.
5//! - Saving space where it is limited, like in SMS or Push messages.
6//!
7//! # Examples
8//! ```
9//! use squishyid::SquishyID;
10//!
11//! let s = SquishyID::new(
12//!     "2BjLhRduC6Tb8Q5cEk9oxnFaWUDpOlGAgwYzNre7tI4yqPvXm0KSV1fJs3ZiHM"
13//! ).unwrap();
14//!
15//! let encoded: String = s.encode(48888851145);
16//! assert_eq!(encoded, "1FN7Ab");
17//!
18//! let decoded: u64 = s.decode("1FN7Ab").unwrap();
19//! assert_eq!(decoded, 48888851145);
20//! ```
21//!
22//! Check out [SquishyID] for detailed methods description.
23//!
24//! # Other implementations
25//! - [Raku](https://github.com/bbkr/TinyID)
26//! - [PHP](https://github.com/krowinski/tinyID)
27//! - [Perl](http://search.cpan.org/~bbkr/Integer-Tiny-0.3/lib/Integer/Tiny.pm)
28
29use std::collections::HashMap;
30
31// Debug is intentionally not derived: printing this struct would reveal the key.
32pub struct SquishyID {
33    length: usize,
34    characters_to_positions: HashMap<char, usize>,
35    positions_to_characters: Vec<char>,
36}
37
38impl SquishyID {
39    /// Constructs new instance using given key.
40    ///
41    /// - It must consist of at least two unique unicode characters.
42    /// - The **longer the key** - the **shorter encoded ID** will be.
43    /// - Encoded ID will be **made exclusively out of characters from the key**.
44    ///
45    /// Choose your key characters wisely, for example:
46    /// - For SMS messages generate key from `a-z,A-Z,0-9` range.
47    ///   You will get excellent shortening like `1234567890` -> `380FQs`.
48    /// - For NTFS file names generate key from `a-z` range.
49    ///   You will get good shortening and avoid case insensitivity collisions, like `1234567890` -> `iszbmfx`.
50    /// - When trolling generate key from Emojis.
51    ///   So `1234567890` will be represented as `๐Ÿ˜ฃ๐Ÿ˜„๐Ÿ˜น๐Ÿ˜ง๐Ÿ˜‹๐Ÿ˜ณ`.
52    ///
53    /// # Errors
54    /// - `Key must contain at least 2 characters.`
55    /// - `Key must contain unique characters.`
56    pub fn new(key: &str) -> Result<Self, &str> {
57        let positions_to_characters: Vec<char> = key.chars().collect();
58
59        let length: usize = positions_to_characters.len();
60        if length < 2 {
61            return Err("Key must contain at least 2 characters.");
62        }
63
64        let mut characters_to_positions: HashMap<char, usize> = HashMap::new();
65        for (position, &character) in positions_to_characters.iter().enumerate() {
66            if characters_to_positions
67                .insert(character, position)
68                .is_some()
69            {
70                return Err("Key must contain unique characters.");
71            }
72        }
73
74        Ok(Self {
75            length,
76            characters_to_positions,
77            positions_to_characters,
78        })
79    }
80
81    /// Encodes number using characters from the key.
82    ///
83    /// Note that **this should not be considered a strong encryption**.
84    /// It does not contain consistency checks.
85    /// And key is easy to reverse engineer with small amount of encoded/decoded samples given.
86    /// Treat it as really, really fast obfuscation only.
87    pub fn encode(&self, mut decoded: u64) -> String {
88        let mut encoded: Vec<char> = Vec::new();
89
90        loop {
91            let position: u64 = decoded % (self.length as u64);
92            encoded.push(self.positions_to_characters[position as usize]);
93            decoded /= self.length as u64;
94
95            if decoded == 0 {
96                break;
97            }
98        }
99
100        encoded.iter().rev().collect()
101    }
102
103    /// Decodes string using characters from the key.
104    ///
105    /// # Errors
106    /// - `Encoded value must contain at least 1 character.`
107    /// - `Encoded value contains character not present in key.`
108    /// - `Encoded value too big to decode.` - when it would cause `u64` overflow.
109    pub fn decode(&self, encoded: &str) -> Result<u64, &str> {
110        if encoded.is_empty() {
111            return Err("Encoded value must contain at least 1 character.");
112        }
113
114        let mut decoded: u64 = 0;
115
116        for (position, character) in encoded.chars().rev().enumerate() {
117            let factor: u64 = match self.characters_to_positions.get(&character) {
118                None => return Err("Encoded value contains character not present in key."),
119                Some(&factor) => factor as u64,
120            };
121
122            match (self.length as u64)
123                .checked_pow(position as u32)
124                .and_then(|a| a.checked_mul(factor))
125                .and_then(|a| a.checked_add(decoded))
126            {
127                None => return Err("Encoded value too big to decode."),
128                Some(bigger_decoded) => decoded = bigger_decoded,
129            }
130        }
131
132        Ok(decoded)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn key_too_short() {
142        assert!(matches!(
143            SquishyID::new(""),
144            Err("Key must contain at least 2 characters.")
145        ));
146        assert!(matches!(
147            SquishyID::new("a"),
148            Err("Key must contain at least 2 characters.")
149        ));
150    }
151
152    #[test]
153    fn key_not_unique() {
154        assert!(matches!(
155            SquishyID::new("aa"),
156            Err("Key must contain unique characters.")
157        ));
158        assert!(matches!(
159            SquishyID::new("aba"),
160            Err("Key must contain unique characters.")
161        ));
162    }
163
164    #[test]
165    fn key_valid() {
166        assert!(SquishyID::new("ab").is_ok());
167    }
168
169    #[test]
170    fn transcode_0_value() {
171        let s = SquishyID::new("ab").unwrap();
172        assert_eq!(s.encode(0), "a");
173        assert_eq!(s.decode("a").unwrap(), 0);
174    }
175
176    #[test]
177    fn transcode_u64_value() {
178        let s = SquishyID::new("FujSBZHkPMincNQr6pq0mgxw2tXAsyb8DWV534EC1RUIlYoGOJhed9afKT7vzL")
179            .unwrap();
180        assert_eq!(s.encode(u64::MAX), "gzUp3uHipVr");
181        assert_eq!(s.decode("gzUp3uHipVr").unwrap(), u64::MAX);
182    }
183
184    #[test]
185    fn transcode_non_ascii() {
186        let s = SquishyID::new("รคฤ…").unwrap();
187        assert_eq!(s.encode(8), "ฤ…รครครค");
188        assert_eq!(s.decode("ฤ…รครครค").unwrap(), 8);
189
190        let s = SquishyID::new("๐Ÿ˜€๐Ÿ˜๐Ÿ˜‚๐Ÿ˜ƒ๐Ÿ˜„๐Ÿ˜…๐Ÿ˜†๐Ÿ˜‡๐Ÿ˜ˆ๐Ÿ˜‰๐Ÿ˜Š๐Ÿ˜‹๐Ÿ˜Œ๐Ÿ˜๐Ÿ˜Ž๐Ÿ˜๐Ÿ˜๐Ÿ˜‘๐Ÿ˜’๐Ÿ˜“๐Ÿ˜”๐Ÿ˜•๐Ÿ˜–๐Ÿ˜—๐Ÿ˜˜๐Ÿ˜™๐Ÿ˜š๐Ÿ˜›๐Ÿ˜œ๐Ÿ˜๐Ÿ˜ž๐Ÿ˜Ÿ๐Ÿ˜ ๐Ÿ˜ก๐Ÿ˜ข๐Ÿ˜ฃ๐Ÿ˜ค๐Ÿ˜ฅ๐Ÿ˜ฆ๐Ÿ˜ง๐Ÿ˜จ๐Ÿ˜ฉ๐Ÿ˜ช๐Ÿ˜ซ๐Ÿ˜ฌ๐Ÿ˜ญ๐Ÿ˜ฎ๐Ÿ˜ฏ๐Ÿ˜ฐ๐Ÿ˜ฑ๐Ÿ˜ฒ๐Ÿ˜ณ๐Ÿ˜ด๐Ÿ˜ต๐Ÿ˜ถ๐Ÿ˜ท").unwrap();
191        assert_eq!(s.encode(48888851145), "๐Ÿ˜๐Ÿ˜ ๐Ÿ˜ซ๐Ÿ˜ˆ๐Ÿ˜ต๐Ÿ˜‡๐Ÿ˜");
192        assert_eq!(s.decode("๐Ÿ˜๐Ÿ˜ ๐Ÿ˜ซ๐Ÿ˜ˆ๐Ÿ˜ต๐Ÿ˜‡๐Ÿ˜").unwrap(), 48888851145);
193    }
194
195    #[test]
196    fn decode_empty_string() {
197        let s = SquishyID::new("ab").unwrap();
198        assert!(matches!(
199            s.decode(""),
200            Err("Encoded value must contain at least 1 character.")
201        ));
202    }
203
204    #[test]
205    fn decode_character_not_in_key() {
206        let s = SquishyID::new("ab").unwrap();
207        assert!(matches!(
208            s.decode("x"),
209            Err("Encoded value contains character not present in key.")
210        ));
211    }
212
213    #[test]
214    fn decode_overflow() {
215        let s = SquishyID::new("0123456789ABCDEF").unwrap();
216        assert!(matches!(
217            s.decode("10000000000000000"),
218            Err("Encoded value too big to decode.")
219        ));
220    }
221}