use crate::storage::{self, TrustedEntry}; use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use parking_lot::{Mutex as PlMutex, RwLock}; use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ collections::{hash_map::Entry, BTreeSet, HashMap, VecDeque}, fmt::Display, net::{IpAddr, Ipv4Addr}, path::PathBuf, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Arc, }, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use tokio::sync::{Mutex, Notify, Semaphore}; // ───────────────────────── Лимиты ───────────────────────── pub const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024 * 1024; // один файл pub const MAX_MEDIA_QUOTA: u64 = 10 * 1024 * 1024 * 1024; // всего в received_media pub const MAX_TEXT_BYTES: usize = 64 * 1024; pub const MAX_RAM_TEXTS: usize = 50; pub const MAX_LOGS: usize = 100; pub const MAX_PEERS: usize = 256; pub const MAX_PEER_IPS: usize = 4; pub const MAX_TRANSFERS: usize = 20; pub const MAX_INCOMING: usize = 4; pub const MAX_MEDIA_LIST: usize = 500; pub const DISCOVERY_PORT: u16 = 5354; pub const MIN_PING_SEC: u64 = 10; pub const MAX_PING_SEC: u64 = 30; pub const DEFAULT_PING_SEC: u64 = 15; pub const MAX_PING_FAILS: u8 = 5; // ───────────────────────── Ошибки ───────────────────────── pub struct AppError(pub StatusCode, pub String); impl AppError { pub fn new(status: StatusCode, msg: impl Into) -> Self { Self(status, msg.into()) } /// Детали пишем в stderr, наружу отдаём общий текст (без путей и внутренностей). pub fn internal(ctx: &str, e: impl Display) -> Self { eprintln!("[ERR] {}: {}", ctx, e); Self(StatusCode::INTERNAL_SERVER_ERROR, "Внутренняя ошибка".into()) } } impl IntoResponse for AppError { fn into_response(self) -> Response { (self.0, self.1).into_response() } } // ───────────────────────── DTO ───────────────────────── #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TextItem { pub sender: String, pub text: String, pub timestamp: u64, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MediaItem { pub sender: String, pub filename: String, pub saved_path: String, pub mime: String, pub size: u64, pub timestamp: u64, pub previewable: bool, pub risky: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct PeerDevice { pub id: String, pub name: String, pub ips: Vec, pub port: u16, pub fingerprint: String, /// Код сверки (SAS): зависит от ОБОИХ сертификатов, на двух устройствах одинаков. pub pin_code: String, /// Мы сверили код и подтвердили это устройство (наше решение; по нему принимаем/отправляем). pub verified: bool, /// Собеседник подтвердил сверку с НАШИМ устройством (узнаём по mTLS-каналу из /p2p/info). pub trusts_me: bool, pub online: bool, /// Когда устройство последний раз отвечало по сети (unix timestamp, сек). pub last_seen: u64, /// Когда мы последний раз опрашивали (пинговали) это устройство (unix timestamp, сек). pub last_ping: u64, /// Количество подряд неудачных попыток пинга (при достижении 5 автоопрос прекращается). pub fail_count: u8, } impl PeerDevice { pub fn new(fp: &str, name: String, ips: Vec, port: u16, my_fp: &str, verified: bool) -> Self { Self { id: fp.to_string(), name, ips, port, fingerprint: fp.to_string(), pin_code: sas_code(my_fp, fp), verified, trusts_me: false, online: false, last_seen: 0, last_ping: 0, fail_count: 0, } } } #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Direction { In, Out, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TransferProgress { pub id: String, pub filename: String, pub peer_name: String, pub direction: Direction, pub transferred_bytes: u64, pub total_bytes: u64, pub percent: u8, pub done: bool, pub error: Option, pub updated_at: u64, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct ScanProgress { pub active: bool, pub range: String, pub scanned: usize, pub total: usize, pub found: usize, } #[derive(Serialize, Deserialize)] pub struct IncomingTextPayload { pub text: String, } #[derive(Deserialize)] pub struct UiSendTextRequest { pub peer_id: String, pub text: String, } #[derive(Deserialize)] pub struct UiTrustRequest { pub peer_id: String, pub trust: bool, } #[derive(Deserialize)] pub struct UiPingPeerRequest { pub peer_id: String, } #[derive(Deserialize)] pub struct UiManualPeerRequest { pub ip: String, pub port: Option, } #[derive(Deserialize)] pub struct UiScanRangeRequest { pub range: String, pub port: Option, } #[derive(Deserialize)] pub struct UiSettingsRequest { pub ping_interval_sec: u64, } // ───────────────────────── Состояние ───────────────────────── pub struct AppState { pub device_name: String, pub my_ip: RwLock, pub fixed_ip: bool, pub p2p_port: u16, pub my_fingerprint: String, pub cert_der: Vec, pub key_der: Vec, pub data_dir: PathBuf, pub media_dir: PathBuf, pub ram_texts: RwLock>, pub disk_media: RwLock>, pub peers: Arc>>, pub probe_client: reqwest::Client, pub transfers: RwLock>, pub scan_status: RwLock, pub logs: RwLock>, pub incoming_slots: Arc, pub ping_lock: Mutex<()>, pub trust_dirty: AtomicBool, pub probe_cooldown: PlMutex>, /// Интервал опроса известных пиров (сек), меняется на лету pub ping_interval_sec: AtomicU64, /// Время последнего глобального опроса устройств (unix timestamp, сек) pub last_ping: AtomicU64, pub ping_notify: Notify, /// true в режиме `serve`: журнал дублируется в stdout (в CLI-командах stdout занят результатом) pub log_stdout: bool, /// true в `serve --no-ui`: входящий текст печатается в терминал pub headless: bool, /// mtime trusted.json, чтобы подхватывать правки из CLI (trust/untrust) работающим узлом pub trust_mtime: PlMutex>, } impl AppState { pub fn current_ip(&self) -> String { self.my_ip.read().clone() } pub fn update_my_ip(&self, ip: Ipv4Addr) -> bool { if self.fixed_ip { return false; } let s = ip.to_string(); let mut cur = self.my_ip.write(); if *cur != s { *cur = s; true } else { false } } pub fn add_log(&self, msg: impl Into) { // защита от log-injection: управляющие символы в именах и т.п. let text: String = msg .into() .chars() .map(|c| if c.is_control() { ' ' } else { c }) .collect(); if self.log_stdout { println!("[LOG] {}", text); } let mut logs = self.logs.write(); if logs.len() >= MAX_LOGS { logs.pop_back(); } logs.push_front(format!("[{}] {}", now_sec(), text)); } // ── передачи ── pub fn add_transfer(&self, t: TransferProgress) { let mut l = self.transfers.write(); l.insert(0, t); l.truncate(MAX_TRANSFERS); } pub fn update_transfer_bytes(&self, id: &str, bytes: u64) { let mut l = self.transfers.write(); if let Some(t) = l.iter_mut().find(|t| t.id == id) { t.transferred_bytes = bytes; t.percent = percent(bytes, t.total_bytes); t.updated_at = now_sec(); } } pub fn finish_transfer(&self, id: &str) { let mut l = self.transfers.write(); if let Some(t) = l.iter_mut().find(|t| t.id == id) { t.transferred_bytes = t.total_bytes; t.percent = 100; t.done = true; t.updated_at = now_sec(); } } pub fn fail_transfer(&self, id: &str, msg: String) { let mut l = self.transfers.write(); if let Some(t) = l.iter_mut().find(|t| t.id == id) { t.done = true; t.error = Some(msg); t.updated_at = now_sec(); } } pub fn remove_transfer(&self, id: &str) { self.transfers.write().retain(|t| t.id != id); } // ── доверие ── pub fn trusted_name(&self, fp: &str) -> Option { self.peers .read() .get(fp) .filter(|p| p.verified) .map(|p| p.name.clone()) } pub fn persist_trust(&self) { let entries: Vec = self .peers .read() .values() .filter(|p| p.verified) .map(|p| TrustedEntry { fp: p.id.clone(), name: p.name.clone(), ips: p.ips.clone(), port: p.port, trusts_me: p.trusts_me, last_seen: p.last_seen, }) .collect(); match storage::save_trusted(&self.data_dir, &entries) { Ok(()) => { if let Ok(mt) = std::fs::metadata(self.data_dir.join("trusted.json")).and_then(|m| m.modified()) { *self.trust_mtime.lock() = Some(mt); } } Err(e) => self.add_log(format!("Не удалось сохранить список доверенных: {}", e)), } } pub fn client_for_peer(&self, fp: &str, streaming: bool) -> Result { crate::tls::pinned_client( fp.to_string(), self.peers.clone(), &self.cert_der, &self.key_der, streaming, ) .map_err(|e| AppError::internal("tls client", e)) } // ── пиры ── /// Регистрирует/обновляет пира. Вызывать ТОЛЬКО с отпечатком, полученным из TLS-рукопожатия. pub fn upsert_peer(&self, name: &str, ip: IpAddr, port: u16, fp: &str) -> bool { if fp == self.my_fingerprint || !is_valid_fp(fp) || port == 0 { return false; } if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() { return false; } let mut name = clean_label(name, 48); if name.is_empty() { name = "Unknown".into(); } let ip_s = ip.to_string(); let now = now_sec(); let mut created = false; let mut dirty = false; { let mut peers = self.peers.write(); if !peers.contains_key(fp) && peers.len() >= MAX_PEERS { let victim = peers .values() .filter(|p| !p.verified && !p.online) .min_by_key(|p| p.last_seen) .map(|p| p.id.clone()); match victim { Some(v) => { peers.remove(&v); } None => return false, } } match peers.entry(fp.to_string()) { Entry::Occupied(mut e) => { let p = e.get_mut(); let changed = p.ips.first() != Some(&ip_s) || p.port != port || p.name != name; p.name = name.clone(); p.port = port; p.online = true; p.fail_count = 0; p.last_seen = now; p.last_ping = now; p.ips.retain(|x| x != &ip_s); p.ips.insert(0, ip_s.clone()); p.ips.truncate(MAX_PEER_IPS); if p.verified && changed { dirty = true; } } Entry::Vacant(v) => { let mut p = PeerDevice::new(fp, name.clone(), vec![ip_s.clone()], port, &self.my_fingerprint, false); p.online = true; p.fail_count = 0; p.last_seen = now; p.last_ping = now; v.insert(p); created = true; } } } if dirty { self.trust_dirty.store(true, Ordering::Relaxed); } if created { self.add_log(format!( "Обнаружено устройство '{}' ({}:{}), код сверки: {}", name, ip, port, sas_code(&self.my_fingerprint, fp) )); } created } /// Обновляет активный IP и время активности уже известного пира (при входящем/исходящем обмене). pub fn touch_peer_ip(&self, fp: &str, ip: IpAddr) { if ip.is_unspecified() || ip.is_loopback() || ip.is_multicast() { return; } let ip_s = ip.to_string(); let now = now_sec(); let mut dirty = false; if let Some(p) = self.peers.write().get_mut(fp) { let changed = p.ips.first() != Some(&ip_s); p.online = true; p.fail_count = 0; p.last_seen = now; if changed { p.ips.retain(|x| x != &ip_s); p.ips.insert(0, ip_s); p.ips.truncate(MAX_PEER_IPS); if p.verified { dirty = true; } } } if dirty { self.trust_dirty.store(true, Ordering::Relaxed); } } /// Сбрасывает счётчик неудачных пингов у всех известных устройств (вызывается при ручном опросе или сканировании). pub fn reset_all_ping_failures(&self) { let mut peers = self.peers.write(); for p in peers.values_mut() { p.fail_count = 0; } } /// Пир уже недавно виден по этому адресу и порту — повторный probe по beacon не нужен. pub fn is_fresh(&self, fp: &str, ip: IpAddr, port: u16) -> bool { let ip_s = ip.to_string(); self.peers.read().get(fp).map_or(false, |p| { p.online && p.port == port && p.ips.first().map(String::as_str) == Some(ip_s.as_str()) && now_sec().saturating_sub(p.last_seen) < 10 }) } pub fn probe_allowed(&self, ip: IpAddr, port: u16) -> bool { let mut m = self.probe_cooldown.lock(); let now = Instant::now(); if m.len() > 512 { m.retain(|_, t| now.duration_since(*t) < Duration::from_secs(15)); } match m.get(&(ip, port)) { Some(t) if now.duration_since(*t) < Duration::from_secs(4) => false, _ => { m.insert((ip, port), now); true } } } /// Меняет интервал опроса: применяется сразу и сохраняется в config.json. pub fn set_ping_interval(&self, sec: u64) -> Result<(), String> { if !(MIN_PING_SEC..=MAX_PING_SEC).contains(&sec) { return Err(format!("Интервал должен быть от {} до {} секунд", MIN_PING_SEC, MAX_PING_SEC)); } self.ping_interval_sec.store(sec, Ordering::Relaxed); self.ping_notify.notify_one(); // будим цикл опроса, чтобы он взял новое значение if let Err(e) = storage::update_config(&self.data_dir, |c| c.ping_interval_sec = Some(sec)) { self.add_log(format!("Не удалось сохранить config.json: {}", e)); } self.add_log(format!("Интервал опроса устройств: {} с", sec)); Ok(()) } /// Бит «собеседник подтвердил сверку с нами». Источник — только аутентифицированный (mTLS) /// канал: заголовок/ответ /p2p/info от владельца отпечатка. pub fn set_remote_trust(&self, fp: &str, v: bool) { let (msg, hint) = { let mut peers = self.peers.write(); let Some(p) = peers.get_mut(fp) else { return }; if p.trusts_me == v { return; } p.trusts_me = v; if v && !p.verified { ( format!( "«{}» запрашивает взаимную сверку. Код сверки: {} — сравните его на экране устройства и подтвердите", p.name, p.pin_code ), Some(short_id(fp)), ) } else if v { (format!("«{}» подтвердил сверку — доверие взаимное", p.name), None) } else { (format!("«{}» отозвал доверие к вашему устройству", p.name), None) } }; self.trust_dirty.store(true, Ordering::Relaxed); self.add_log(msg); if let (true, Some(id)) = (self.headless, hint) { println!(" → подтвердить: ramshare_ui trust {}", id); } } /// Подхватывает изменения trusted.json, сделанные другим процессом (CLI: trust/untrust). pub fn sync_trust_from_disk(&self) { let path = self.data_dir.join("trusted.json"); let Ok(mt) = std::fs::metadata(&path).and_then(|m| m.modified()) else { return }; { let mut last = self.trust_mtime.lock(); if *last == Some(mt) { return; } *last = Some(mt); } let Some(entries) = storage::read_trusted_strict(&self.data_dir) else { return }; let mut peers = self.peers.write(); for p in peers.values_mut() { p.verified = entries.iter().any(|e| e.fp == p.id); } for e in entries { if !is_valid_fp(&e.fp) || e.fp == self.my_fingerprint || peers.contains_key(&e.fp) { continue; } if peers.len() >= MAX_PEERS { break; } let ips: Vec = e .ips .into_iter() .filter(|s| s.parse::().is_ok()) .take(MAX_PEER_IPS) .collect(); let port = if e.port == 0 { 8443 } else { e.port }; let mut np = PeerDevice::new(&e.fp, clean_label(&e.name, 48), ips, port, &self.my_fingerprint, true); np.trusts_me = e.trusts_me; np.last_seen = e.last_seen; peers.insert(e.fp.clone(), np); } } pub fn maintenance(&self) { self.sync_trust_from_disk(); let now = now_sec(); self.peers .write() .retain(|_, p| p.verified || p.online || now.saturating_sub(p.last_seen) < 600); if self.trust_dirty.swap(false, Ordering::Relaxed) { self.persist_trust(); } } } pub fn remove_transfer_later(state: &Arc, id: String, secs: u64) { let st = state.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(secs)).await; st.remove_transfer(&id); }); } // ───────────────────────── Утилиты ───────────────────────── pub fn now_sec() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } pub fn percent(done: u64, total: u64) -> u8 { if total == 0 { 100 } else { ((done as u128 * 100) / total as u128).min(100) as u8 } } pub fn fingerprint(der: &[u8]) -> String { hex::encode(Sha256::digest(der)) } pub fn is_valid_fp(fp: &str) -> bool { fp.len() == 64 && fp.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) } pub fn short_id(fp: &str) -> String { fp.get(..8).unwrap_or(fp).to_uppercase() } /// SAS-код: SHA-256(sorted(fpA, fpB)), 80 бит, группы по 4 символа. pub fn sas_code(a: &str, b: &str) -> String { let (x, y) = if a <= b { (a, b) } else { (b, a) }; let mut h = Sha256::new(); h.update(b"ramshare-sas-v1"); h.update(x.as_bytes()); h.update(b"|"); h.update(y.as_bytes()); let digest = h.finalize(); let s = hex::encode_upper(&digest[..10]); s.as_bytes() .chunks(4) .map(|c| std::str::from_utf8(c).unwrap_or("")) .collect::>() .join("-") } pub fn random_hex(n: usize) -> String { let mut b = vec![0u8; n]; getrandom::getrandom(&mut b).expect("ОС не предоставила случайные байты"); hex::encode(b) } /// Имя устройства и т.п.: без управляющих и bidi-символов, ограниченной длины. pub fn clean_label(s: &str, max_chars: usize) -> String { s.chars() .filter(|c| { !c.is_control() && !matches!(*c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{200e}' | '\u{200f}') }) .collect::() .trim() .chars() .take(max_chars) .collect() } /// Для печати чужого текста в терминал: убирает управляющие/escape-символы (кроме \n и \t). pub fn strip_ctrl(s: &str) -> String { s.chars() .map(|c| if c.is_control() && c != '\n' && c != '\t' { '\u{fffd}' } else { c }) .collect() } pub fn pct_decode(s: &str) -> String { percent_decode_str(s).decode_utf8_lossy().into_owned() } pub fn pct_encode(s: &str) -> String { utf8_percent_encode(s, NON_ALPHANUMERIC).to_string() } pub fn hdr<'a>(h: &'a HeaderMap, name: &str) -> Option<&'a str> { h.get(name).and_then(|v| v.to_str().ok()) } /// Локальные IPv4: RFC 1918 (10/8, 172.16/12, 192.168/16), Link-Local (169.254/16) и CGNAT/VPN (100.64/10). pub fn is_lan_v4(ip: Ipv4Addr) -> bool { let o = ip.octets(); ip.is_private() || ip.is_link_local() || (o[0] == 100 && (o[1] & 0b1100_0000) == 0b0100_0000) } /// Принимает уже раскодированное имя. Ничего не декодирует. pub fn sanitize_filename(name: &str) -> String { let clean: String = name .chars() .map(|c| match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', c if c.is_control() => '_', c => c, }) .collect(); let trimmed = clean.trim_matches(|c: char| c == '.' || c.is_whitespace()); if trimmed.is_empty() { return "file.bin".into(); } const MAX: usize = 120; if trimmed.chars().count() <= MAX { return trimmed.to_string(); } match trimmed.rfind('.') { Some(i) if trimmed[i..].chars().count() <= 16 => { let ext = &trimmed[i..]; let keep = MAX - ext.chars().count(); format!("{}{}", trimmed[..i].chars().take(keep).collect::(), ext) } _ => trimmed.chars().take(MAX).collect(), } } /// Диапазоны: "all", "192.168.1.1-254", "192.168.1.10-192.168.1.80", "192.168.1.0/24" (маски /22–/32), /// одиночные адреса и списки через запятую. Только локальные IPv4. pub fn parse_ip_range(input: &str, my_ip: &str) -> Result<(String, Vec), String> { const MAX_TOTAL: usize = 1024; let raw = input.trim(); let mine: Option = my_ip .parse::() .ok() .filter(|ip| is_lan_v4(*ip)) .or_else(crate::net::detect_lan_ip); let effective = if raw.is_empty() || raw.eq_ignore_ascii_case("all") || raw.eq_ignore_ascii_case("full") { let base = mine.ok_or("Не удалось определить подсеть автоматически. Введите диапазон вручную.")?; let o = base.octets(); format!("{}.{}.{}.1-254", o[0], o[1], o[2]) } else { raw.to_string() }; if effective.len() > 256 { return Err("Слишком длинная строка диапазона".into()); } let mut set: BTreeSet = BTreeSet::new(); let mut tokens = 0; for part in effective.split(',') { let token = part.trim(); if token.is_empty() { continue; } tokens += 1; if tokens > 16 { return Err("Слишком много элементов в списке (максимум 16)".into()); } let (start, end): (u32, u32) = if let Some((base, pfx)) = token.split_once('/') { let ip: Ipv4Addr = base.trim().parse().map_err(|_| format!("Некорректный CIDR адрес: {}", token))?; let p: u32 = pfx.trim().parse().map_err(|_| format!("Некорректная маска: {}", token))?; if !(22..=32).contains(&p) { return Err("Поддерживаются маски от /22 до /32".into()); } let mask: u32 = !0u32 << (32 - p); let s = u32::from(ip) & mask; (s, s | !mask) } else if let Some((left, right)) = token.split_once('-') { let (left, right) = (left.trim(), right.trim()); let s_ip: Ipv4Addr = left.parse().map_err(|_| format!("Некорректный начальный IP: {}", left))?; let s = u32::from(s_ip); let e = if right.contains('.') { let e_ip: Ipv4Addr = right.parse().map_err(|_| format!("Некорректный конечный IP: {}", right))?; u32::from(e_ip) } else { let last: u8 = right.parse().map_err(|_| format!("Некорректный конец диапазона: {}", right))?; (s & !0xFF) | last as u32 }; if e < s { return Err("Конец диапазона меньше начала".into()); } (s, e) } else { let ip: Ipv4Addr = token.parse().map_err(|_| format!("Некорректный IP-адрес: {}", token))?; (u32::from(ip), u32::from(ip)) }; if end - start > MAX_TOTAL as u32 { return Err(format!("Диапазон слишком велик (максимум {} адресов)", MAX_TOTAL)); } for v in start..=end { let ip = Ipv4Addr::from(v); let last = ip.octets()[3]; if start != end && (last == 0 || last == 255) { continue; } if !is_lan_v4(ip) { return Err(format!("Сканирование разрешено только в локальных сетях: {}", ip)); } set.insert(v); if set.len() > MAX_TOTAL { return Err(format!("Слишком много адресов (максимум {})", MAX_TOTAL)); } } } let result: Vec = set .into_iter() .map(Ipv4Addr::from) .filter(|ip| Some(*ip) != mine && !ip.is_loopback() && !ip.is_unspecified()) .collect(); if result.is_empty() { return Err("Диапазон не содержит доступных для сканирования IP-адресов".into()); } Ok((effective, result)) } #[cfg(test)] mod tests { use super::*; #[test] fn range_short_form() { let (_, v) = parse_ip_range("192.168.1.10-12", "192.168.1.11").unwrap(); assert_eq!(v.len(), 2); } #[test] fn range_rejects_public() { assert!(parse_ip_range("8.8.8.8", "192.168.1.2").is_err()); } #[test] fn range_all_and_dedup() { let (_, v) = parse_ip_range("all", "10.0.0.5").unwrap(); assert_eq!(v.len(), 253); let (_, v) = parse_ip_range("10.0.0.1,10.0.0.1,10.0.0.2", "10.0.0.5").unwrap(); assert_eq!(v.len(), 2); } #[test] fn range_too_big() { assert!(parse_ip_range("10.0.0.0/16", "10.0.0.5").is_err()); } #[test] fn filenames() { assert_eq!(sanitize_filename("../../etc/passwd"), "_.._etc_passwd"); assert_eq!(sanitize_filename("a+b%25.txt"), "a+b%25.txt"); assert_eq!(sanitize_filename("..."), "file.bin"); assert!(sanitize_filename(&"x".repeat(500)).chars().count() <= 120); } #[test] fn fp_validation() { assert!(is_valid_fp(&"a".repeat(64))); assert!(!is_valid_fp("x');alert(1)//")); assert!(!is_valid_fp(&"A".repeat(64))); } #[test] fn sas_symmetric() { let a = "a".repeat(64); let b = "b".repeat(64); assert_eq!(sas_code(&a, &b), sas_code(&b, &a)); assert_eq!(sas_code(&a, &b).len(), 24); } }