Skip to main content

cloudillo_types/
utils.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Utility functions
5
6use std::borrow::Cow;
7
8use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
9use serde::de::DeserializeOwned;
10
11use crate::prelude::*;
12use rand::RngExt;
13
14pub const ID_LENGTH: usize = 24;
15pub const SAFE: [char; 62] = [
16	'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
17	'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B',
18	'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U',
19	'V', 'W', 'X', 'Y', 'Z',
20];
21
22/// Derive default display name from id_tag
23///
24/// Takes first portion (before '.'), capitalizes first letter.
25///
26/// # Examples
27/// - `"home.w9.hu"` → `"Home"`
28/// - `"john.example.com"` → `"John"`
29/// - `"alice"` → `"Alice"`
30pub fn derive_name_from_id_tag(id_tag: &str) -> String {
31	let first_part = id_tag.split('.').next().unwrap_or(id_tag);
32	let mut chars = first_part.chars();
33	match chars.next() {
34		Some(c) => c.to_uppercase().chain(chars).collect(),
35		None => id_tag.to_string(),
36	}
37}
38
39/// Canonical form of an id_tag **for use as a lookup key**.
40///
41/// Delegates to [`crate::validation::canonicalize_id_tag`], the single definition of the
42/// canonical (UTS #46 U-label) form every id_tag-valued column in the meta and auth
43/// databases stores. The invariant is enforced on write only — there is no backfill
44/// migration, because every deployed id_tag is already canonical ASCII.
45///
46/// Infallible on purpose: this is a key normaliser, not a validator. An input that cannot
47/// be canonicalised is passed through trimmed, so a read simply matches nothing — the
48/// correct failure mode for a lookup. Writes are gated by
49/// [`crate::validation::validate_id_tag`] at the boundaries (Action format, federation
50/// client), so a non-canonical value cannot reach storage in the first place.
51///
52/// Borrows when the input is already canonical, so hot read paths do not allocate.
53pub fn normalize_id_tag(id_tag: &str) -> Cow<'_, str> {
54	crate::validation::canonicalize_id_tag(id_tag).unwrap_or(Cow::Borrowed(id_tag.trim()))
55}
56
57pub fn random_id() -> ClResult<String> {
58	let mut rng = rand::rng();
59	let mut result = String::with_capacity(ID_LENGTH);
60
61	for _ in 0..ID_LENGTH {
62		result.push(SAFE[rng.random_range(0..SAFE.len())]);
63	}
64	Ok(result)
65}
66
67/// Decode a JWT payload without verifying the signature.
68///
69/// WARNING: This MUST always be followed by proper signature verification.
70/// It only peeks at the payload to determine routing info (issuer, key_id, etc.).
71pub fn decode_jwt_no_verify<T: DeserializeOwned>(jwt: &str) -> ClResult<T> {
72	let mut parts = jwt.splitn(3, '.');
73	let _header = parts.next().ok_or(Error::Parse)?;
74	let payload = parts.next().ok_or(Error::Parse)?;
75	let _sig = parts.next().ok_or(Error::Parse)?;
76	let payload = URL_SAFE_NO_PAD.decode(payload.as_bytes()).map_err(|_| Error::Parse)?;
77	let payload: T = serde_json::from_slice(&payload).map_err(|_| Error::Parse)?;
78	Ok(payload)
79}
80
81/// Parse and validate an identity id_tag against a registrar's domain.
82///
83/// Splits a fully-qualified identity id_tag (e.g., "alice.example.com") into prefix and domain
84/// components, validating that the domain matches the registrar's domain.
85pub fn parse_and_validate_identity_id_tag(
86	id_tag: &str,
87	registrar_domain: &str,
88) -> ClResult<(String, String)> {
89	// Validate inputs
90	if registrar_domain.is_empty() {
91		return Err(Error::ValidationError("Registrar domain cannot be empty".to_string()));
92	}
93	if id_tag.is_empty() {
94		return Err(Error::ValidationError("Identity id_tag cannot be empty".to_string()));
95	}
96
97	// Check if id_tag ends with the registrar's domain as a suffix with a dot separator
98	let domain_with_dot = format!(".{}", registrar_domain);
99	if let Some(pos) = id_tag.rfind(&domain_with_dot) {
100		let prefix = id_tag[..pos].to_string();
101		if prefix.is_empty() {
102			return Err(Error::ValidationError(
103				"Invalid id_tag: prefix cannot be empty (id_tag must be in format 'prefix.domain')"
104					.to_string(),
105			));
106		}
107		Ok((prefix, registrar_domain.to_string()))
108	} else if id_tag == registrar_domain {
109		// Special case: id_tag is exactly the domain (empty prefix)
110		Err(Error::ValidationError(
111			"Invalid id_tag: prefix cannot be empty (id_tag must be in format 'prefix.domain')"
112				.to_string(),
113		))
114	} else {
115		Err(Error::ValidationError(format!(
116			"Identity id_tag '{}' does not match registrar domain '{}'",
117			id_tag, registrar_domain
118		)))
119	}
120}
121
122/// Parse a comma-separated role list into the shape `AuthCtx::roles` expects.
123///
124/// Empty segments are dropped, because `"".split(',')` yields one empty item and a
125/// `""` entry reads as "has a role" in `file_access::role_access_level`, granting
126/// every federated stranger access to tenant-owned files. Every conversion from a
127/// stored/claimed role string to `Box<[Box<str>]>` must go through here.
128pub fn parse_roles(roles: &str) -> Box<[Box<str>]> {
129	roles.split(',').filter(|s| !s.is_empty()).map(Into::into).collect()
130}
131
132/// Mask an email for safe display: "al***@ex***.com"
133pub fn mask_email(email: &str) -> Option<String> {
134	let (local, domain) = email.split_once('@')?;
135	let (domain_name, tld) = domain.rsplit_once('.')?;
136
137	if local.is_empty() || domain_name.is_empty() {
138		return None;
139	}
140
141	let local_visible = &local[..local.len().min(1)];
142	let domain_visible = &domain_name[..domain_name.len().min(1)];
143
144	Some(format!("{}***@{}***.{}", local_visible, domain_visible, tld))
145}
146
147#[cfg(test)]
148mod tests {
149	use super::*;
150
151	#[test]
152	fn test_derive_name_from_id_tag() {
153		assert_eq!(derive_name_from_id_tag("home.w9.hu"), "Home");
154		assert_eq!(derive_name_from_id_tag("john.example.com"), "John");
155		assert_eq!(derive_name_from_id_tag("alice"), "Alice");
156		assert_eq!(derive_name_from_id_tag("UPPER.test"), "UPPER");
157		assert_eq!(derive_name_from_id_tag(""), "");
158	}
159
160	#[test]
161	fn test_normalize_id_tag() {
162		// Already canonical — borrows, no allocation.
163		assert!(matches!(
164			normalize_id_tag("alice.example.com"),
165			Cow::Borrowed("alice.example.com")
166		));
167		assert_eq!(normalize_id_tag("Alice.Example.COM"), "alice.example.com");
168		// Trimming alone still borrows.
169		assert!(matches!(normalize_id_tag("  alice.example.com \t"), Cow::Borrowed(_)));
170		assert_eq!(normalize_id_tag("  alice.example.com \t"), "alice.example.com");
171		assert_eq!(normalize_id_tag(" Alice.Example.com "), "alice.example.com");
172		// Empty stays empty (via the pass-through arm — `""` is not canonicalisable).
173		assert_eq!(normalize_id_tag(""), "");
174		assert_eq!(normalize_id_tag("   "), "");
175		// Passed through trimmed rather than mangled: as a lookup needle it then matches
176		// nothing, the correct failure mode for a read.
177		assert_eq!(normalize_id_tag("alice_123"), "alice_123");
178		// IDN: the stored form is the decoded, case-folded U-label.
179		assert_eq!(normalize_id_tag("MÜNCHEN.example.com"), "münchen.example.com");
180		assert_eq!(normalize_id_tag("xn--mnchen-3ya.example.com"), "münchen.example.com");
181	}
182
183	#[test]
184	fn test_simple_valid_identity() {
185		let result = parse_and_validate_identity_id_tag("alice.example.com", "example.com");
186		assert!(result.is_ok());
187		let (prefix, domain) = result.unwrap();
188		assert_eq!(prefix, "alice");
189		assert_eq!(domain, "example.com");
190	}
191
192	#[test]
193	fn test_multi_part_prefix_valid() {
194		let result = parse_and_validate_identity_id_tag("alice.bob.example.com", "example.com");
195		assert!(result.is_ok());
196		let (prefix, domain) = result.unwrap();
197		assert_eq!(prefix, "alice.bob");
198		assert_eq!(domain, "example.com");
199	}
200
201	#[test]
202	fn test_empty_prefix_fails() {
203		let result = parse_and_validate_identity_id_tag("example.com", "example.com");
204		assert!(result.is_err());
205	}
206
207	#[test]
208	fn test_domain_mismatch_fails() {
209		let result = parse_and_validate_identity_id_tag("alice.other.com", "example.com");
210		assert!(result.is_err());
211	}
212
213	#[test]
214	fn test_empty_id_tag_fails() {
215		let result = parse_and_validate_identity_id_tag("", "example.com");
216		assert!(result.is_err());
217	}
218
219	#[test]
220	fn test_empty_registrar_domain_fails() {
221		let result = parse_and_validate_identity_id_tag("alice.example.com", "");
222		assert!(result.is_err());
223	}
224	#[test]
225	fn test_parse_roles() {
226		let as_vec =
227			|s: &str| -> Vec<String> { parse_roles(s).iter().map(ToString::to_string).collect() };
228		assert!(parse_roles("").is_empty());
229		assert_eq!(as_vec("leader"), vec!["leader"]);
230		assert_eq!(as_vec("public,leader"), vec!["public", "leader"]);
231		// Stray separators must not produce `""` entries.
232		assert_eq!(as_vec(",,leader,"), vec!["leader"]);
233	}
234
235	#[test]
236	fn test_mask_email() {
237		assert_eq!(super::mask_email("alice@example.com"), Some("a***@e***.com".to_string()));
238		assert_eq!(super::mask_email("a@x.co"), Some("a***@x***.co".to_string()));
239		assert_eq!(super::mask_email("bob@sub.domain.org"), Some("b***@s***.org".to_string()));
240		assert_eq!(super::mask_email("no-at-sign"), None);
241		assert_eq!(super::mask_email("user@nodot"), None);
242		assert_eq!(super::mask_email("@example.com"), None);
243		assert_eq!(super::mask_email("user@.com"), None);
244		assert_eq!(super::mask_email(""), None);
245	}
246}
247
248// vim: ts=4