cloudillo-types 0.8.18

Shared types, adapter traits, and error types for the Cloudillo federated collaboration platform
Documentation
// SPDX-FileCopyrightText: Szilárd Hajba
// SPDX-License-Identifier: LGPL-3.0-or-later

//! Utility functions

use std::borrow::Cow;

use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use serde::de::DeserializeOwned;

use crate::prelude::*;
use rand::RngExt;

pub const ID_LENGTH: usize = 24;
pub const SAFE: [char; 62] = [
	'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i',
	'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B',
	'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U',
	'V', 'W', 'X', 'Y', 'Z',
];

/// Derive default display name from id_tag
///
/// Takes first portion (before '.'), capitalizes first letter.
///
/// # Examples
/// - `"home.w9.hu"` → `"Home"`
/// - `"john.example.com"` → `"John"`
/// - `"alice"` → `"Alice"`
pub fn derive_name_from_id_tag(id_tag: &str) -> String {
	let first_part = id_tag.split('.').next().unwrap_or(id_tag);
	let mut chars = first_part.chars();
	match chars.next() {
		Some(c) => c.to_uppercase().chain(chars).collect(),
		None => id_tag.to_string(),
	}
}

/// Canonical form of an id_tag **for use as a lookup key**.
///
/// Delegates to [`crate::validation::canonicalize_id_tag`], the single definition of the
/// canonical (UTS #46 U-label) form every id_tag-valued column in the meta and auth
/// databases stores. The invariant is enforced on write only — there is no backfill
/// migration, because every deployed id_tag is already canonical ASCII.
///
/// Infallible on purpose: this is a key normaliser, not a validator. An input that cannot
/// be canonicalised is passed through trimmed, so a read simply matches nothing — the
/// correct failure mode for a lookup. Writes are gated by
/// [`crate::validation::validate_id_tag`] at the boundaries (Action format, federation
/// client), so a non-canonical value cannot reach storage in the first place.
///
/// Borrows when the input is already canonical, so hot read paths do not allocate.
pub fn normalize_id_tag(id_tag: &str) -> Cow<'_, str> {
	crate::validation::canonicalize_id_tag(id_tag).unwrap_or(Cow::Borrowed(id_tag.trim()))
}

pub fn random_id() -> ClResult<String> {
	let mut rng = rand::rng();
	let mut result = String::with_capacity(ID_LENGTH);

	for _ in 0..ID_LENGTH {
		result.push(SAFE[rng.random_range(0..SAFE.len())]);
	}
	Ok(result)
}

/// Decode a JWT payload without verifying the signature.
///
/// WARNING: This MUST always be followed by proper signature verification.
/// It only peeks at the payload to determine routing info (issuer, key_id, etc.).
pub fn decode_jwt_no_verify<T: DeserializeOwned>(jwt: &str) -> ClResult<T> {
	let mut parts = jwt.splitn(3, '.');
	let _header = parts.next().ok_or(Error::Parse)?;
	let payload = parts.next().ok_or(Error::Parse)?;
	let _sig = parts.next().ok_or(Error::Parse)?;
	let payload = URL_SAFE_NO_PAD.decode(payload.as_bytes()).map_err(|_| Error::Parse)?;
	let payload: T = serde_json::from_slice(&payload).map_err(|_| Error::Parse)?;
	Ok(payload)
}

/// Parse and validate an identity id_tag against a registrar's domain.
///
/// Splits a fully-qualified identity id_tag (e.g., "alice.example.com") into prefix and domain
/// components, validating that the domain matches the registrar's domain.
pub fn parse_and_validate_identity_id_tag(
	id_tag: &str,
	registrar_domain: &str,
) -> ClResult<(String, String)> {
	// Validate inputs
	if registrar_domain.is_empty() {
		return Err(Error::ValidationError("Registrar domain cannot be empty".to_string()));
	}
	if id_tag.is_empty() {
		return Err(Error::ValidationError("Identity id_tag cannot be empty".to_string()));
	}

	// Check if id_tag ends with the registrar's domain as a suffix with a dot separator
	let domain_with_dot = format!(".{}", registrar_domain);
	if let Some(pos) = id_tag.rfind(&domain_with_dot) {
		let prefix = id_tag[..pos].to_string();
		if prefix.is_empty() {
			return Err(Error::ValidationError(
				"Invalid id_tag: prefix cannot be empty (id_tag must be in format 'prefix.domain')"
					.to_string(),
			));
		}
		Ok((prefix, registrar_domain.to_string()))
	} else if id_tag == registrar_domain {
		// Special case: id_tag is exactly the domain (empty prefix)
		Err(Error::ValidationError(
			"Invalid id_tag: prefix cannot be empty (id_tag must be in format 'prefix.domain')"
				.to_string(),
		))
	} else {
		Err(Error::ValidationError(format!(
			"Identity id_tag '{}' does not match registrar domain '{}'",
			id_tag, registrar_domain
		)))
	}
}

/// Parse a comma-separated role list into the shape `AuthCtx::roles` expects.
///
/// Empty segments are dropped, because `"".split(',')` yields one empty item and a
/// `""` entry reads as "has a role" in `file_access::role_access_level`, granting
/// every federated stranger access to tenant-owned files. Every conversion from a
/// stored/claimed role string to `Box<[Box<str>]>` must go through here.
pub fn parse_roles(roles: &str) -> Box<[Box<str>]> {
	roles.split(',').filter(|s| !s.is_empty()).map(Into::into).collect()
}

/// Mask an email for safe display: "al***@ex***.com"
pub fn mask_email(email: &str) -> Option<String> {
	let (local, domain) = email.split_once('@')?;
	let (domain_name, tld) = domain.rsplit_once('.')?;

	if local.is_empty() || domain_name.is_empty() {
		return None;
	}

	let local_visible = &local[..local.len().min(1)];
	let domain_visible = &domain_name[..domain_name.len().min(1)];

	Some(format!("{}***@{}***.{}", local_visible, domain_visible, tld))
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn test_derive_name_from_id_tag() {
		assert_eq!(derive_name_from_id_tag("home.w9.hu"), "Home");
		assert_eq!(derive_name_from_id_tag("john.example.com"), "John");
		assert_eq!(derive_name_from_id_tag("alice"), "Alice");
		assert_eq!(derive_name_from_id_tag("UPPER.test"), "UPPER");
		assert_eq!(derive_name_from_id_tag(""), "");
	}

	#[test]
	fn test_normalize_id_tag() {
		// Already canonical — borrows, no allocation.
		assert!(matches!(
			normalize_id_tag("alice.example.com"),
			Cow::Borrowed("alice.example.com")
		));
		assert_eq!(normalize_id_tag("Alice.Example.COM"), "alice.example.com");
		// Trimming alone still borrows.
		assert!(matches!(normalize_id_tag("  alice.example.com \t"), Cow::Borrowed(_)));
		assert_eq!(normalize_id_tag("  alice.example.com \t"), "alice.example.com");
		assert_eq!(normalize_id_tag(" Alice.Example.com "), "alice.example.com");
		// Empty stays empty (via the pass-through arm — `""` is not canonicalisable).
		assert_eq!(normalize_id_tag(""), "");
		assert_eq!(normalize_id_tag("   "), "");
		// Passed through trimmed rather than mangled: as a lookup needle it then matches
		// nothing, the correct failure mode for a read.
		assert_eq!(normalize_id_tag("alice_123"), "alice_123");
		// IDN: the stored form is the decoded, case-folded U-label.
		assert_eq!(normalize_id_tag("MÜNCHEN.example.com"), "münchen.example.com");
		assert_eq!(normalize_id_tag("xn--mnchen-3ya.example.com"), "münchen.example.com");
	}

	#[test]
	fn test_simple_valid_identity() {
		let result = parse_and_validate_identity_id_tag("alice.example.com", "example.com");
		assert!(result.is_ok());
		let (prefix, domain) = result.unwrap();
		assert_eq!(prefix, "alice");
		assert_eq!(domain, "example.com");
	}

	#[test]
	fn test_multi_part_prefix_valid() {
		let result = parse_and_validate_identity_id_tag("alice.bob.example.com", "example.com");
		assert!(result.is_ok());
		let (prefix, domain) = result.unwrap();
		assert_eq!(prefix, "alice.bob");
		assert_eq!(domain, "example.com");
	}

	#[test]
	fn test_empty_prefix_fails() {
		let result = parse_and_validate_identity_id_tag("example.com", "example.com");
		assert!(result.is_err());
	}

	#[test]
	fn test_domain_mismatch_fails() {
		let result = parse_and_validate_identity_id_tag("alice.other.com", "example.com");
		assert!(result.is_err());
	}

	#[test]
	fn test_empty_id_tag_fails() {
		let result = parse_and_validate_identity_id_tag("", "example.com");
		assert!(result.is_err());
	}

	#[test]
	fn test_empty_registrar_domain_fails() {
		let result = parse_and_validate_identity_id_tag("alice.example.com", "");
		assert!(result.is_err());
	}
	#[test]
	fn test_parse_roles() {
		let as_vec =
			|s: &str| -> Vec<String> { parse_roles(s).iter().map(ToString::to_string).collect() };
		assert!(parse_roles("").is_empty());
		assert_eq!(as_vec("leader"), vec!["leader"]);
		assert_eq!(as_vec("public,leader"), vec!["public", "leader"]);
		// Stray separators must not produce `""` entries.
		assert_eq!(as_vec(",,leader,"), vec!["leader"]);
	}

	#[test]
	fn test_mask_email() {
		assert_eq!(super::mask_email("alice@example.com"), Some("a***@e***.com".to_string()));
		assert_eq!(super::mask_email("a@x.co"), Some("a***@x***.co".to_string()));
		assert_eq!(super::mask_email("bob@sub.domain.org"), Some("b***@s***.org".to_string()));
		assert_eq!(super::mask_email("no-at-sign"), None);
		assert_eq!(super::mask_email("user@nodot"), None);
		assert_eq!(super::mask_email("@example.com"), None);
		assert_eq!(super::mask_email("user@.com"), None);
		assert_eq!(super::mask_email(""), None);
	}
}

// vim: ts=4