Files
2026-11-05 12:52:32 +01:00

277 lines
8.1 KiB
Rust

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<Cidr> {
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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => 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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&apos;"),
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("</script>"),
"style" => skip_until = Some("</style>"),
"head" => skip_until = Some("</head>"),
"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("<html><head><meta charset=\"utf-8\"></head><body><div style=\"white-space:pre-wrap;font-family:sans-serif\">");
out.push_str(&html_escape(text));
out.push_str("</div></body></html>");
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("<html><head><style>x{}</style></head><body><p>Hello&nbsp;<b>World</b></p><br>a &amp; b</body></html>");
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"));
}
}