Files
bichon/crates/core/src/utils/mod.rs
T

501 lines
14 KiB
Rust

//
// Copyright (c) 2025-2026 rustmailer.com (https://rustmailer.com)
//
// This file is part of the Bichon Email Archiving Project
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{fs, io, path::PathBuf};
use crate::error::BichonResult;
use base64::engine::general_purpose::STANDARD;
use base64::{engine::general_purpose, Engine};
use rand::{rng, RngExt};
use super::error::code::ErrorCode;
pub mod encrypt;
pub mod html;
pub mod net;
pub mod rate_limit;
pub mod shutdown;
pub mod tls;
#[macro_export]
macro_rules! bichon_version {
() => {
env!("CARGO_PKG_VERSION")
};
}
#[macro_export]
macro_rules! utc_now {
() => {{
use chrono::Utc;
Utc::now().timestamp_millis()
}};
}
#[macro_export]
macro_rules! after_n_days_timestamp {
($start_ts:expr, $days:expr) => {{
const MILLIS_PER_DAY: i64 = 86_400_000; // 24 * 60 * 60 * 1000
$start_ts + ($days as i64) * MILLIS_PER_DAY
}};
}
#[macro_export]
macro_rules! base64_encode {
($bytes:expr) => {{
use base64::{engine::general_purpose::STANDARD, *};
STANDARD.encode($bytes)
}};
}
#[macro_export]
macro_rules! base64_decode {
($key:expr) => {{
use base64::{engine::general_purpose::STANDARD, *};
STANDARD.decode($key).unwrap()
}};
}
#[macro_export]
macro_rules! base64_decode_url_safe {
($key:expr) => {{
use base64::{engine::general_purpose::URL_SAFE, *};
URL_SAFE.decode($key)
}};
}
#[macro_export]
macro_rules! base64_encode_url_safe {
($key:expr) => {{
use base64::{engine::general_purpose::URL_SAFE, *};
URL_SAFE.encode($key)
}};
}
#[macro_export]
macro_rules! product_public_key {
() => {
$crate::base64_decode!(r#"BNlT+WjdEls9VGfry+zKygx+UoypxSqsMBddMGxYgbhWOz7Xfh7YJXGMeby9jBtbz3rhSGrTuZCYA9uwwMMYkhI="#)
};
}
#[macro_export]
macro_rules! license_header {
() => {
"{\"alg\":\"ES256\",\"typ\":\"JWT\"}"
};
}
#[macro_export]
macro_rules! raise_error {
($msg:expr, $code:expr) => {
$crate::error::BichonError::Generic {
message: $msg,
code: $code,
location: snafu::location!(),
}
};
}
#[macro_export]
macro_rules! run_with_timeout {
($duration:expr, $task:expr, $err_msg:expr) => {{
match tokio::time::timeout($duration, $task).await {
Ok(result) => Ok(result),
Err(_) => Err($err_msg),
}
}};
}
#[macro_export]
macro_rules! free_memory {
() => {{
let mut sys = sysinfo::System::new_all();
sys.refresh_memory();
sys.free_memory()
}};
}
#[macro_export]
macro_rules! generate_token {
($bit_strength:expr) => {{
$crate::utils::generate_token_impl($bit_strength)
}};
}
pub(crate) fn generate_token_impl(bit_strength: usize) -> String {
let byte_length = (bit_strength + 23) / 24 * 3;
let random_bytes: Vec<u8> = (0..byte_length).map(|_| rand::random::<u8>()).collect();
let mut encoded = general_purpose::URL_SAFE.encode(&random_bytes);
encoded = encoded
.chars()
.map(|c| {
if c == '/' || c == '+' || c == '-' || c == '_' {
make_single_random_char()
} else {
c
}
})
.collect();
encoded
}
fn make_single_random_char() -> char {
let random_bytes: [u8; 3] = rng().random();
let encoded = general_purpose::URL_SAFE.encode(random_bytes);
encoded
.chars()
.find(|&c| c != '-' && c != '_' && c != '+' && c != '/')
.unwrap_or('a')
}
#[macro_export]
macro_rules! ensure_access {
($dir:expr) => {{
$crate::utils::ensure_dir_and_test_access($dir)
}};
}
#[macro_export]
macro_rules! decode_mailbox_name {
($name:expr) => {{
utf7_imap::decode_utf7_imap($name.to_string())
}};
}
#[macro_export]
macro_rules! encode_mailbox_name {
($name:expr) => {{
utf7_imap::encode_utf7_imap($name.to_string())
}};
}
#[macro_export]
macro_rules! get_encoding {
($label:expr) => {
match encoding_rs::Encoding::for_label($label.as_bytes()) {
None => None,
Some(encoding) => Some(encoding),
}
};
}
#[macro_export]
macro_rules! current_datetime {
() => {{
use chrono::Local;
let now = Local::now();
now.format("%Y%m%d%H%M").to_string()
}};
}
#[macro_export]
macro_rules! validate_email {
($email:expr) => {{
$crate::utils::validate_email($email)
}};
}
#[macro_export]
macro_rules! encrypt {
($plaintext:expr) => {{
$crate::utils::encrypt::encrypt_string($plaintext)
}};
}
#[macro_export]
macro_rules! decrypt {
($plaintext:expr) => {{
$crate::utils::encrypt::decrypt_string($plaintext)
}};
}
pub fn validate_email(email: &str) -> crate::error::BichonResult<()> {
use std::str::FromStr;
let email_address = email_address::EmailAddress::from_str(email).map_err(|_| {
raise_error!(
format!("Invalid email format : {}", email),
ErrorCode::InvalidParameter
)
})?;
if email != email_address.email() {
return Err(raise_error!(
format!("Invalid email format: {}", email),
ErrorCode::InvalidParameter
));
}
Ok(())
}
#[macro_export]
macro_rules! id {
($bit_strength:expr) => {{
// Generate a token with the given bit strength
let token = $crate::utils::generate_token_impl($bit_strength);
// Hash the generated token
$crate::utils::hash(&token)
}};
}
#[macro_export]
macro_rules! u64_to_str {
($id:expr) => {{
let mut buf = itoa::Buffer::new();
buf.format($id)
}};
}
/// Generates a 64-bit hash from a string, ensuring the output is within JavaScript's safe integer range (0 to 2^53 - 1).
pub fn hash(s: &str) -> u64 {
let mut cursor = Vec::new();
cursor.extend_from_slice(s.as_bytes());
let mut cursor = std::io::Cursor::new(cursor);
let hash = murmur3::murmur3_x64_128(&mut cursor, 0).unwrap();
(hash & 0x1F_FFFF_FFFF_FFFF) as u64
}
pub fn hex_hash(s: &str) -> String {
let mut cursor = Vec::new();
cursor.extend_from_slice(s.as_bytes());
let mut cursor = std::io::Cursor::new(cursor);
let hash = murmur3::murmur3_x64_128(&mut cursor, 0).unwrap();
format!("{:032x}", hash)
}
pub fn create_hash(account_id: u64, field: &str) -> u64 {
let mut buffer = Vec::new();
buffer.extend_from_slice(&account_id.to_le_bytes());
buffer.push(b':'); // Separator
buffer.extend_from_slice(field.as_bytes());
let mut cursor = std::io::Cursor::new(buffer);
let hash = murmur3::murmur3_x64_128(&mut cursor, 0).unwrap();
(hash & 0x1F_FFFF_FFFF_FFFF) as u64
}
pub fn create_hash2(account_id: u64, field1: u64, field2: &str) -> u64 {
let mut buffer = Vec::new();
buffer.extend_from_slice(&account_id.to_le_bytes());
buffer.push(b':'); // Separator
buffer.extend_from_slice(&field1.to_le_bytes());
buffer.push(b':'); // Separator
buffer.extend_from_slice(field2.as_bytes());
let mut cursor = std::io::Cursor::new(buffer);
let hash = murmur3::murmur3_x64_128(&mut cursor, 0).unwrap();
(hash & 0x1F_FFFF_FFFF_FFFF) as u64
}
pub fn get_total_size(path: &PathBuf) -> io::Result<u64> {
if !path.exists() {
return Ok(0);
}
if path.is_file() {
return Ok(fs::metadata(path)?.len());
}
let mut total_size = 0u64;
for entry in fs::read_dir(path)? {
let entry = entry?;
let entry_path = entry.path();
if entry_path.is_file() {
total_size += fs::metadata(&entry_path)?.len();
} else if entry_path.is_dir() {
total_size += get_total_size(&entry_path)?;
}
}
Ok(total_size)
}
const MAX_AVATAR_BYTES: usize = 128 * 1024;
pub fn decode_avatar_bytes(base64_str: &str) -> BichonResult<Vec<u8>> {
let bytes = STANDARD.decode(base64_str).map_err(|e| {
raise_error!(
format!("Invalid avatar base64 encoding: {}", e),
ErrorCode::InvalidParameter
)
})?;
if bytes.len() > MAX_AVATAR_BYTES {
return Err(raise_error!(
format!(
"Avatar image exceeds maximum size ({} KB).",
MAX_AVATAR_BYTES / 1024
),
ErrorCode::InvalidParameter
));
}
Ok(bytes)
}
pub fn compute_content_hash(content: &[u8]) -> String {
let hash = blake3::hash(content);
hash.to_hex().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
// ── hash ──────────────────────────────────────────────────────────
#[test]
fn hash_is_deterministic() {
let a = hash("hello world");
let b = hash("hello world");
assert_eq!(a, b);
}
#[test]
fn hash_different_inputs_produce_different_outputs() {
let a = hash("hello");
let b = hash("world");
assert_ne!(a, b);
}
#[test]
fn hash_empty_string() {
let h = hash("");
assert!(h < (1u64 << 53));
}
#[test]
fn hex_hash_is_deterministic() {
let a = hex_hash("test");
let b = hex_hash("test");
assert_eq!(a, b);
assert_eq!(a.len(), 32);
}
#[test]
fn hex_hash_different_inputs_produce_different_outputs() {
assert_ne!(hex_hash("a"), hex_hash("b"));
}
// ── create_hash / create_hash2 ────────────────────────────────────
#[test]
fn create_hash_deterministic() {
let a = create_hash(1, "INBOX");
let b = create_hash(1, "INBOX");
assert_eq!(a, b);
}
#[test]
fn create_hash_different_accounts_differ() {
assert_ne!(create_hash(1, "INBOX"), create_hash(2, "INBOX"));
}
#[test]
fn create_hash_different_fields_differ() {
assert_ne!(create_hash(1, "INBOX"), create_hash(1, "Sent"));
}
#[test]
fn create_hash2_deterministic() {
let a = create_hash2(1, 100, "INBOX");
let b = create_hash2(1, 100, "INBOX");
assert_eq!(a, b);
}
#[test]
fn create_hash2_different_inputs_differ() {
assert_ne!(create_hash2(1, 100, "INBOX"), create_hash2(1, 200, "INBOX"));
assert_ne!(create_hash2(1, 100, "INBOX"), create_hash2(1, 100, "Sent"));
}
// ── compute_content_hash ──────────────────────────────────────────
#[test]
fn compute_content_hash_deterministic() {
let data = b"test content";
let a = compute_content_hash(data);
let b = compute_content_hash(data);
assert_eq!(a, b);
assert_eq!(a.len(), 64);
}
#[test]
fn compute_content_hash_different_content_differ() {
assert_ne!(compute_content_hash(b"a"), compute_content_hash(b"b"));
}
// ── validate_email ────────────────────────────────────────────────
#[test]
fn validate_email_valid() {
assert!(validate_email("user@example.com").is_ok());
assert!(validate_email("a@b.co").is_ok());
assert!(validate_email("test.user+tag@domain.com").is_ok());
}
#[test]
fn validate_email_invalid() {
assert!(validate_email("not-an-email").is_err());
assert!(validate_email("").is_err());
assert!(validate_email("@domain.com").is_err());
assert!(validate_email("user@").is_err());
}
// ── generate_token_impl ───────────────────────────────────────────
#[test]
fn generate_token_has_expected_length() {
let token = generate_token_impl(256);
// URL-safe base64 encodes 3 bytes → 4 chars, so length is roughly
// ceil(bit_strength / 24) * 4, but chars like /+=-_ are replaced
assert!(!token.is_empty());
}
#[test]
fn generate_token_does_not_contain_special_chars() {
for _ in 0..10 {
let token = generate_token_impl(256);
assert!(!token.contains('/'));
assert!(!token.contains('+'));
assert!(!token.contains('-'));
assert!(!token.contains('_'));
}
}
#[test]
fn generate_token_is_random() {
let a = generate_token_impl(256);
let b = generate_token_impl(256);
assert_ne!(a, b);
}
// ── decode_avatar_bytes ───────────────────────────────────────────
#[test]
fn decode_avatar_bytes_valid() {
// "avatar" in base64 = "YXZhdGFy"
let result = decode_avatar_bytes("YXZhdGFy").unwrap();
assert_eq!(result, b"avatar");
}
#[test]
fn decode_avatar_bytes_invalid_base64() {
assert!(decode_avatar_bytes("!!!invalid!!!").is_err());
}
}