Skip to main content

cloudillo_types/
utils.rs

1//! Utility functions
2
3use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
4use serde::de::DeserializeOwned;
5
6use crate::prelude::*;
7use rand::RngExt;
8
9pub const ID_LENGTH: usize = 24;
10pub const SAFE: [char; 62] = [
11	'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
12	'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B',
13	'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U',
14	'V', 'W', 'X', 'Y', 'Z',
15];
16
17/// Derive default display name from id_tag
18///
19/// Takes first portion (before '.'), capitalizes first letter.
20///
21/// # Examples
22/// - `"home.w9.hu"` → `"Home"`
23/// - `"john.example.com"` → `"John"`
24/// - `"alice"` → `"Alice"`
25pub fn derive_name_from_id_tag(id_tag: &str) -> String {
26	let first_part = id_tag.split('.').next().unwrap_or(id_tag);
27	let mut chars = first_part.chars();
28	match chars.next() {
29		Some(c) => c.to_uppercase().chain(chars).collect(),
30		None => id_tag.to_string(),
31	}
32}
33
34pub fn random_id() -> ClResult<String> {
35	let mut rng = rand::rng();
36	let mut result = String::with_capacity(ID_LENGTH);
37
38	for _ in 0..ID_LENGTH {
39		result.push(SAFE[rng.random_range(0..SAFE.len())]);
40	}
41	Ok(result)
42}
43
44/// Decode a JWT payload without verifying the signature.
45///
46/// WARNING: This MUST always be followed by proper signature verification.
47/// It only peeks at the payload to determine routing info (issuer, key_id, etc.).
48pub fn decode_jwt_no_verify<T: DeserializeOwned>(jwt: &str) -> ClResult<T> {
49	let mut parts = jwt.splitn(3, '.');
50	let _header = parts.next().ok_or(Error::Parse)?;
51	let payload = parts.next().ok_or(Error::Parse)?;
52	let _sig = parts.next().ok_or(Error::Parse)?;
53	let payload = URL_SAFE_NO_PAD.decode(payload.as_bytes()).map_err(|_| Error::Parse)?;
54	let payload: T = serde_json::from_slice(&payload).map_err(|_| Error::Parse)?;
55	Ok(payload)
56}
57
58/// Parse and validate an identity id_tag against a registrar's domain.
59///
60/// Splits a fully-qualified identity id_tag (e.g., "alice.example.com") into prefix and domain
61/// components, validating that the domain matches the registrar's domain.
62pub fn parse_and_validate_identity_id_tag(
63	id_tag: &str,
64	registrar_domain: &str,
65) -> ClResult<(String, String)> {
66	// Validate inputs
67	if registrar_domain.is_empty() {
68		return Err(Error::ValidationError("Registrar domain cannot be empty".to_string()));
69	}
70	if id_tag.is_empty() {
71		return Err(Error::ValidationError("Identity id_tag cannot be empty".to_string()));
72	}
73
74	// Check if id_tag ends with the registrar's domain as a suffix with a dot separator
75	let domain_with_dot = format!(".{}", registrar_domain);
76	if let Some(pos) = id_tag.rfind(&domain_with_dot) {
77		let prefix = id_tag[..pos].to_string();
78		if prefix.is_empty() {
79			return Err(Error::ValidationError(
80				"Invalid id_tag: prefix cannot be empty (id_tag must be in format 'prefix.domain')"
81					.to_string(),
82			));
83		}
84		Ok((prefix, registrar_domain.to_string()))
85	} else if id_tag == registrar_domain {
86		// Special case: id_tag is exactly the domain (empty prefix)
87		Err(Error::ValidationError(
88			"Invalid id_tag: prefix cannot be empty (id_tag must be in format 'prefix.domain')"
89				.to_string(),
90		))
91	} else {
92		Err(Error::ValidationError(format!(
93			"Identity id_tag '{}' does not match registrar domain '{}'",
94			id_tag, registrar_domain
95		)))
96	}
97}
98
99#[cfg(test)]
100mod tests {
101	use super::*;
102
103	#[test]
104	fn test_derive_name_from_id_tag() {
105		assert_eq!(derive_name_from_id_tag("home.w9.hu"), "Home");
106		assert_eq!(derive_name_from_id_tag("john.example.com"), "John");
107		assert_eq!(derive_name_from_id_tag("alice"), "Alice");
108		assert_eq!(derive_name_from_id_tag("UPPER.test"), "UPPER");
109		assert_eq!(derive_name_from_id_tag(""), "");
110	}
111
112	#[test]
113	fn test_simple_valid_identity() {
114		let result = parse_and_validate_identity_id_tag("alice.example.com", "example.com");
115		assert!(result.is_ok());
116		let (prefix, domain) = result.unwrap();
117		assert_eq!(prefix, "alice");
118		assert_eq!(domain, "example.com");
119	}
120
121	#[test]
122	fn test_multi_part_prefix_valid() {
123		let result = parse_and_validate_identity_id_tag("alice.bob.example.com", "example.com");
124		assert!(result.is_ok());
125		let (prefix, domain) = result.unwrap();
126		assert_eq!(prefix, "alice.bob");
127		assert_eq!(domain, "example.com");
128	}
129
130	#[test]
131	fn test_empty_prefix_fails() {
132		let result = parse_and_validate_identity_id_tag("example.com", "example.com");
133		assert!(result.is_err());
134	}
135
136	#[test]
137	fn test_domain_mismatch_fails() {
138		let result = parse_and_validate_identity_id_tag("alice.other.com", "example.com");
139		assert!(result.is_err());
140	}
141
142	#[test]
143	fn test_empty_id_tag_fails() {
144		let result = parse_and_validate_identity_id_tag("", "example.com");
145		assert!(result.is_err());
146	}
147
148	#[test]
149	fn test_empty_registrar_domain_fails() {
150		let result = parse_and_validate_identity_id_tag("alice.example.com", "");
151		assert!(result.is_err());
152	}
153}
154
155// vim: ts=4