use std::net::IpAddr; use rand::RngCore; use sha2::{Digest, Sha256}; pub fn now() -> i64 { chrono::Utc::now().timestamp() } pub fn random_token(bytes: usize) -> String { let mut b = vec![0u8; bytes]; rand::rngs::OsRng.fill_bytes(&mut b); hex::encode(b) } pub fn sha256_hex(data: &[u8]) -> String { hex::encode(Sha256::digest(data)) } pub fn ct_eq(a: &[u8], b: &[u8]) -> bool { use subtle::ConstantTimeEq; a.len() == b.len() && bool::from(a.ct_eq(b)) } #[derive(Debug, Clone, Copy)] pub struct Cidr { addr: IpAddr, prefix: u8, } impl Cidr { pub fn parse(s: &str) -> Option { let s = s.trim(); let (a, p) = match s.split_once('/') { Some((a, p)) => (a, Some(p)), None => (s, None), }; let addr: IpAddr = a.parse().ok()?; let max = if addr.is_ipv4() { 32 } else { 128 }; let prefix = match p { Some(p) => p.parse().ok().filter(|x: &u8| *x <= max)?, None => max, }; Some(Cidr { addr, prefix }) } pub fn contains(&self, ip: &IpAddr) -> bool { let ip = normalize(*ip); match (self.addr, ip) { (IpAddr::V4(n), IpAddr::V4(i)) => { let mask = if self.prefix == 0 { 0 } else { u32::MAX << (32 - self.prefix) }; (u32::from(n) & mask) == (u32::from(i) & mask) } (IpAddr::V6(n), IpAddr::V6(i)) => { let mask = if self.prefix == 0 { 0 } else { u128::MAX << (128 - self.prefix) }; (u128::from(n) & mask) == (u128::from(i) & mask) } _ => false, } } } pub fn normalize(ip: IpAddr) -> IpAddr { match ip { IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), v4 => v4, } } pub fn ip_in_any(ip: &IpAddr, nets: &[String]) -> bool { nets.iter() .filter_map(|n| Cidr::parse(n)) .any(|c| c.contains(ip)) } pub fn html_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } out } pub fn xml_escape(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), c if (c as u32) < 0x20 && c != '\n' && c != '\r' && c != '\t' => {} _ => out.push(c), } } out } /// Truncate a string to at most `n` bytes on a char boundary. pub fn truncate_bytes(s: &str, n: usize) -> &str { if s.len() <= n { return s; } let mut i = n; while i > 0 && !s.is_char_boundary(i) { i -= 1; } &s[..i] } /// Convert HTML to readable plain text (used when a device wants text but only HTML exists). pub fn html_to_text(html: &str) -> String { let mut out = String::with_capacity(html.len() / 2); let lower = html.to_ascii_lowercase(); let bytes = html.as_bytes(); let mut i = 0; let mut skip_until: Option<&str> = None; while i < bytes.len() { if let Some(end) = skip_until { if lower[i..].starts_with(end) { skip_until = None; i += end.len(); } else { i += 1; } continue; } if bytes[i] == b'<' { let close = match html[i..].find('>') { Some(c) => i + c, None => break, }; let tag = lower[i + 1..close].trim_start_matches('/'); let name: String = tag .chars() .take_while(|c| c.is_ascii_alphanumeric()) .collect(); match name.as_str() { "script" => skip_until = Some(""), "style" => skip_until = Some(""), "head" => skip_until = Some(""), "br" => out.push('\n'), "p" | "div" | "tr" | "li" | "h1" | "h2" | "h3" | "h4" | "blockquote" | "table" => { if !out.ends_with('\n') { out.push('\n'); } if name == "li" && !lower[i + 1..].starts_with('/') { out.push_str("• "); } } "td" | "th" => out.push('\t'), _ => {} } i = close + 1; continue; } if bytes[i] == b'&' { if let Some(semi) = html[i..].find(';').filter(|s| *s < 10) { let ent = &html[i + 1..i + semi]; let rep = match ent { "nbsp" => Some(' '), "amp" => Some('&'), "lt" => Some('<'), "gt" => Some('>'), "quot" => Some('"'), "apos" | "#39" => Some('\''), e if e.starts_with("#x") => u32::from_str_radix(&e[2..], 16) .ok() .and_then(char::from_u32), e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32), _ => None, }; if let Some(c) = rep { out.push(c); i += semi + 1; continue; } } } let ch = html[i..].chars().next().unwrap_or(' '); if ch == '\r' || ch == '\n' || ch == '\t' { if !out.ends_with(' ') && !out.ends_with('\n') { out.push(' '); } } else { out.push(ch); } i += ch.len_utf8(); } // collapse 3+ newlines let mut res = String::with_capacity(out.len()); let mut nl = 0; for c in out.chars() { if c == '\n' { nl += 1; if nl > 2 { continue; } } else { nl = 0; } res.push(c); } res.trim().to_string() } pub fn text_to_html(text: &str) -> String { let mut out = String::from("
"); out.push_str(&html_escape(text)); out.push_str("
"); out } /// Validate a login name received from a device. pub fn valid_login(s: &str) -> bool { !s.is_empty() && s.len() <= 254 && s.chars().all(|c| { !c.is_control() && !c.is_whitespace() && !matches!(c, '/' | '\\' | '"' | '\'' | '<' | '>' | '%' | '*' | '?') }) } pub fn format_ts(ts: i64) -> String { chrono::DateTime::from_timestamp(ts, 0) .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string()) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; #[test] fn cidr() { let c = Cidr::parse("10.0.0.0/8").unwrap(); assert!(c.contains(&"10.2.3.4".parse().unwrap())); assert!(!c.contains(&"11.2.3.4".parse().unwrap())); let l = Cidr::parse("127.0.0.1").unwrap(); assert!(l.contains(&"::ffff:127.0.0.1".parse().unwrap())); assert!(Cidr::parse("::1/128") .unwrap() .contains(&"::1".parse().unwrap())); assert!(Cidr::parse("1.2.3.4/33").is_none()); } #[test] fn html2text() { let t = html_to_text("

Hello World


a & b"); assert_eq!(t, "Hello World\n\na & b"); } #[test] fn logins() { assert!(valid_login("jan.kowalski@firma.pl")); assert!(!valid_login("a b")); assert!(!valid_login("../etc")); } }